Compare commits

...
Author SHA1 Message Date
Paul Bottein 4205fb799b Keep the automation config when replacing a device in device automations 2026-08-26 15:54:58 +02:00
Maarten LakerveldandGitHub 8d3877c388 Fix Expose page crash when filtered by assistant (#53803)
The assistants URL filter can skip creating a row for entities that are
only exposed via Cloud (Alexa/Google) manual config, but the sortable key
was still assigned to the missing row, throwing a TypeError and leaving
the page on an infinite spinner. Only assign the key when the row exists.

Fixes #53689
2026-08-26 13:43:50 +00:00
Aidan TimsonandGitHub 66b9fbd4bf Fix view background config reference (#53802) 2026-08-26 15:40:31 +02:00
Bram Kragten a406abfdcd Merge branch 'rc' into dev 2026-08-26 14:48:07 +02:00
Bram Kragten fddc6e5506 Bumped version to 20260826.0 2026-08-26 14:47:19 +02:00
Petar PetrovandGitHub 69518334bb Keep the frame minimum when a gap marker shares the frame (#53580)
The chart data modules push a null y value to break the line where an
entity was unavailable. downSampleLineData read it with Number(), and
Number(null) is 0, which is not NaN, so the isNaN guard did not fire.
The marker then competed as a real value of 0 and won its frame's
minimum slot whenever the readings were positive, discarding the
frame's actual minimum and widening the rendered gap.

Keep markers out of the min/max comparisons entirely and hold at most
one per frame in its own slot. It is emitted, after the frame's values,
only when no kept value follows it: a marker followed by a value in its
own frame is a gap that closed within one frame, which is about one
device pixel wide and too narrow to show. That check runs per frame at
emit time, so the per-point path stays as it was. Keeping every marker
instead would blow up the output on series that are mostly null, such
as the climate heating dataset, which went from 823 to 14525 points
before this was bounded.

Skipping markers before the numeric work also makes gapped series
faster: 16% on a series with a few gaps, 27% on one that is mostly
gaps. Both now have benchmark coverage, which the gap path lacked.

Mean mode no longer averages markers in as zero.
2026-08-26 15:29:12 +03:00
Petar PetrovandGitHub 7a387004d6 Cache media source resolutions for view backgrounds (#53615)
Cache resolved media source URLs for view backgrounds
2026-08-26 15:28:34 +03:00
Paul BotteinandGitHub 1e9993785d Add reusable device class picker (#53798) 2026-08-26 13:26:32 +02:00
renovate[bot]andGitHub 1a3a93e3b9 Update dependency cropperjs to v1.6.3 (#53796) 2026-08-26 11:33:32 +01:00
Jan BouwhuisandGitHub c6dd741bb5 Add device class selector (#53672)
* Add sesnor device class selector

* Remove unused constants

* Remove stale test case and import

* Fix CI tests

* Add domain and make selector generic

* Follow up comments

* Use import
2026-08-26 12:23:26 +02:00
Krisjanis LejejsandGitHub 24f522b7ae Improve cloud login/sign up pages, add auto login (#53790)
* Improve cloud login/sign up pages, add auto login

* Improve trual button text

* Improve event subscription, fix email wrapping
2026-08-26 05:17:59 -04:00
Petar PetrovandGitHub 0594229a45 Fix choose-view dialog showing another dashboard's views (#53792)
* Show the selected dashboard's views in the choose-view dialog

* Block the choose-view action while the dashboard config loads
2026-08-26 12:08:06 +03:00
Josef ZweckandGitHub 615fd58324 Allow cross domain translations for config entry setup errors (#53791) 2026-08-26 09:35:48 +01:00
Paul BotteinandGitHub fb35194041 Share favorites editor and security entity filter (#53785) 2026-08-26 09:11:48 +03:00
Paul BotteinandGitHub d4e3ec858e Remove pulse from security dashboard alerts (#53784) 2026-08-26 08:24:31 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6516b8de68 Update dependency @bundle-stats/plugin-webpack-filter to v4.22.3 (#53783)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-25 18:10:10 +02:00
Paul BotteinandGitHub edb2f98d03 Show targets in automation traces (#53718) 2026-08-25 18:50:04 +03:00
Aidan TimsonandGitHub 5ae95a1ea2 Add favorites to Security dashboard (#53512)
* Add favorites to security dashboard

* Use contexts in favorites editor

* Update src/translations/en.json

Co-authored-by: Paul Bottein <[email protected]>

* Address security favorites review

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-08-25 17:46:03 +02:00
Aidan TimsonandGitHub d0371e506f Add active alerts to Security dashboard (#53031)
* Add security dashboard alerts

* Fix security alerts editor and card defaults

* Use consistent security editor icons

* Fix security dashboard alert state

* Fix lint

* Render security alerts as individual cards

* Use context data in security alerts editor

* Add pulse for alerts

Co-authored-by: Paul Bottein <[email protected]>

* Use explicit cover and lock alert states

* Fix test

* Remove security panel rendering tests

* Use complete states

Co-authored-by: Paul Bottein <[email protected]>

* Remove camera

Co-authored-by: Paul Bottein <[email protected]>

* open state only

Co-authored-by: Paul Bottein <[email protected]>

* Remove security entity filter cache

* Format

* Update security alert visibility tests

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-08-25 17:28:46 +02:00
Paul BotteinandGitHub 872205c352 Remove template and default-value tests (#53779) 2026-08-25 17:14:00 +03:00
tormazsandGitHub bf939babfd Fix ha-control-select losing its value when activated by a screen reader (#53777)
The option handlers read the value from ev.target, which is whichever
element the click landed on. Real pointer events are retargeted to the
outer role="radio" element by .option .content { pointer-events: none },
but a screen reader's synthetic activation click is not hit-tested, so it
lands on the inner content element, which has no value.

ev.currentTarget is always the element the listener is bound to, so it
always carries the value.
2026-08-25 15:39:05 +03:00
noksideandGitHub d41b21b9d6 Fix timeCachePromiseFunc cache handling (#53773)
Fix promise cache result handling
2026-08-25 15:31:08 +03:00
Jan BouwhuisandGitHub 7e495f38d2 Expand sensor entity constants and add device classes (#53748)
* Expand sensor entity constants and add device classes

* Updated constants

* Fix typo

* Fix name

* Restore part original generated script comment

* Add convertible units and fix null type

* Fix type

* Update constants

* Fix import SENSOR_NUMERIC_DEVICE_CLASSES
2026-08-25 14:28:13 +02:00
Petar PetrovandGitHub 5cff0cb4c9 Focus nested form-dialog fields when a nested level is pushed (#53757)
* Focus nested form-dialog fields after a nested level is pushed.

Host focus on ha-form does not pierce selector shadows, so keyboard
focus stayed on the opener instead of the nested controls.

* Simplify nested form-dialog focus and fix CI types.

Wait one render and focus the first real control instead of waiting for
custom-element upgrades or walking a general focusable tree.

* Wait for lazy selector upgrades before nested form focus.

nextRender is not enough on a cold selector chunk; wait for undefined
custom elements so nested focus does not depend on network timing.
2026-08-25 15:14:33 +03:00
Paul BotteinandGitHub dbe2e2c079 Reflect the active filters in the target picker counts (#53716) 2026-08-25 13:53:26 +02:00
Paul BotteinandGitHub e22b770252 Clarify when agents should add tests (#53775) 2026-08-25 12:28:47 +01:00
Paul BotteinandGitHub fd388d99bf Show area and disable pulse by default in alert card (#53774) 2026-08-25 12:17:52 +01:00
Aidan TimsonandGitHub f9bfc66d5b Add alert card (#53758)
* Add security alerts card

* Fix security alert cards

* Validate security alert card configuration

* Keep a single generic alert card

* Use entity context in alert card
2026-08-25 10:45:53 +01:00
renovate[bot]andGitHub c4ab017aed Update dependency @rspack/dev-server to v2.2.1 (#53772) 2026-08-25 09:27:59 +01:00
Paul BotteinandGitHub e22ef3e1f3 Merge the domain and device class filters into a type filter (#53710)
* Merge the domain and device class filters into a type filter

* Only scan the entities while the type filter panel is open
2026-08-25 08:12:00 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7a3764790b Update dependency @types/luxon to v3.7.5 (#53770)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-25 08:40:20 +03:00
karwostsandGitHub c499c2e030 Fix more-info setting dirty state tracking (#53762) 2026-08-25 07:02:41 +02:00
13ec52ddbe Fix Z-Wave add node security strategy selection not sticking (#53769)
The security strategy step rendered ha-form with a data object built
from a @state field that was never assigned, so the form was always
bound to an undefined strategy. ha-form's own optimistic data update
made the radio look selected after a click, but the next re-render of
the step (triggered by any hass update) re-committed the undefined
value and cleared the selection.

Make the step a controlled component: the dialog already owns
_inclusionStrategy and resets it when navigating back, so pass it down
as a property instead of keeping a second, unused copy in the child.


Claude-Session: https://claude.ai/code/session_011MGgDoAUxzf9KBexn6uiPc

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 07:01:47 +02:00
d288cfd30a Add supported_speeds parameter for cover movement actions (#53572)
add supported_speeds for cover service actions

Co-authored-by: karwosts <[email protected]>
2026-08-24 20:10:19 +02:00
karwostsandGitHub 643e8934b4 Allow media selector to optionally select a media directory (#27873)
Allow media browser to select a directory
2026-08-24 20:05:21 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9f3ca39738 Update dependency eslint to v10.9.0 (#53765)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-24 17:03:14 +03:00
renovate[bot]andGitHub 8cee5155e6 Update dependency @rsdoctor/rspack-plugin to v1.6.3 (#53761) 2026-08-24 14:37:06 +01:00
Paul BotteinandGitHub 15341a0aad Show climate summary card with only temperature sensors (#53760) 2026-08-24 14:51:04 +02:00
Petar PetrovandGitHub 9f8a2253ac Add keyboard and screen reader navigation to the energy device charts (#53754)
* Sonify value-first charts now that the extension reads them

* Announce device names instead of statistic ids in sonified charts
2026-08-24 14:45:43 +02:00
Maarten LakerveldandGitHub 97df5a5bf0 Key more-info details memoize on attribute name formatter (#53755)
_getDetailData caches on stateObj alone while formatting via this.hass,
so results go stale when translation-based format functions reload (e.g.
backend translations finishing after open, or a language switch) until
the entity next changes state. Pass hass.formatEntityAttributeName as an
extra memoize argument so the cache invalidates when format functions
are recreated.
2026-08-24 11:59:22 +02:00
Bram Kragten 91c28c2f58 Bumped version to 20260729.7 2026-08-14 09:59:26 +02:00
Paul BotteinandBram Kragten 1f299d98e0 Fix clipped focus ring on media player playback buttons (#53632) 2026-08-14 09:58:33 +02:00
Petar PetrovandBram Kragten a74926aef6 Fix sensor card graph footers not receiving hass on lazy upgrade (#53576) 2026-08-14 09:58:32 +02:00
Bram Kragten 437a4cedf9 Check for wheel on PyPI simple index instead of JSON API (#53549) 2026-08-14 09:58:31 +02:00
Bram Kragten 55913d7505 Fix panels missing updates (#53548) 2026-08-14 09:58:30 +02:00
0bb654c70c Mock entity ID format WS commands in the demo (#53520)
Co-authored-by: Claude <[email protected]>
2026-08-14 09:58:30 +02:00
Bram Kragten 235541748b Bumped version to 20260729.6 2026-08-07 16:30:37 +02:00
Paul BotteinandBram Kragten 6bd1d698ce Fix disabled save button when leaving an automation with unsaved changes (#53538) 2026-08-07 16:27:35 +02:00
Bram Kragten 18862b868f Wait with showing pending http changes dialog until loading screen is… (#53534)
* Wait with showing pending http changes dialog until loading screen is gone

* fix timing
2026-08-07 16:27:33 +02:00
Bram Kragten 662d817a5e Fix link to different port being handled as internal link (#53530) 2026-08-07 16:27:32 +02:00
Aidan TimsonandBram Kragten 7412ab5578 Remove "dev" from tools actions description (#53528)
* Remove "dev" from tools actions description

* Clarify vt is browser developer tools
2026-08-07 16:27:31 +02:00
Krisjanis LejejsandBram Kragten b2be0bd673 Remove cloud demo controls (#53524) 2026-08-07 16:27:30 +02:00
Bram Kragten a141432546 Don't disable interaction when row is disabled (#53517)
dont disable interaction when row is disabled
2026-08-07 16:27:29 +02:00
Paul BotteinandBram Kragten cd50531b67 Stack the sidebar when only one column fits (#53515) 2026-08-07 16:27:29 +02:00
Petar PetrovandBram Kragten f7438f4d0e Accept hostnames and localize validation messages in the HTTP config form (#53511)
Accept hostnames in HTTP config listen addresses and localize number validation
2026-08-07 16:27:28 +02:00
Petar PetrovandBram Kragten a7fabb8a8c Only show the sidebar tab switcher when its tabs are labelled (#53508) 2026-08-07 16:26:46 +02:00
Aidan TimsonandBram Kragten 9dab55f39a Fix notification stacking (#53486)
* Stack notifications independently

* Preserve identified notifications on legacy close

* Preserve updated notifications during close

* Fix notification stack positioning

* Update src/managers/notification-manager.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
(cherry picked from commit d03ba15c09)

# Conflicts:
#	test/panels/config/script/script-paste.test.ts
2026-08-07 16:23:38 +02:00
Bram Kragten 7ac98b7fdd Bumped version to 20260729.5 2026-08-05 09:56:45 +02:00
karwostsandBram Kragten 5c1c92e8ca Fix missing label in solar forecast dialog (#53506) 2026-08-05 09:56:31 +02:00
Paul BotteinandBram Kragten bf9765d557 Fix back navigation loop on the cloud page (#53497) 2026-08-05 09:56:30 +02:00
Petar PetrovandBram Kragten b013b1f929 Keep numeric slider and buttons within min and max when the step overshoots (#53489)
* Clamp numeric controls after snapping to the step

* Cover the pointer path, display and arrow keys at the bounds
2026-08-05 09:53:55 +02:00
Petar PetrovandBram Kragten 8026a59e70 Fix wrong month in date picker calendar header (#53453) 2026-08-05 09:50:37 +02:00
Bram Kragten ffa7cf17a6 Bumped version to 20260729.4 2026-08-04 13:19:17 +02:00
karwostsandBram Kragten 5a5cec9060 Fix slow loading of entity rows in device page (#53471)
Fix lazy loading of entity rows in device page
2026-08-04 12:50:36 +02:00
Petar PetrovandBram Kragten bb962ba3d8 Fix energy default period being saved under the wrong storage key (#53462) 2026-08-04 12:50:35 +02:00
d4a59ff1c7 Drop websocket collections created by an iframe panel on teardown (#53448)
* Drop websocket collections created by an iframe panel on teardown

`getCollection()` caches collections (entity registry, label registry, ...) on
the `Connection` object. A custom panel embedded in an iframe shares the
connection with the main window, so a collection the panel is the first to
request is created inside the iframe's realm.

When the user leaves the panel, `ha-panel-custom` removes the iframe and that
realm is destroyed - but the collection stays cached on the connection. Its
store, and the `setTimeout` that hands the cached state to new subscribers, are
gone, so every later subscriber waits forever for a callback that can never
fire. Since the registries moved to `LazyContextProvider` they are subscribed
on demand, which makes an iframe panel the first requester far more often: the
KNX panel's entity table renders empty after leaving and re-entering it, and
the main frontend can inherit the same dead cache.

Clean up from the iframe's own `pagehide` handler, while the realm is still
alive - `disconnectedCallback` on `ha-panel-custom` runs after the browsing
context is discarded, when `contentWindow` is already `null`.

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

* Use unknown instead of any in the CachedCollection shape

The helper never inspects the subscribed state, so the parameter type only
needs to be a placeholder.

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-04 12:50:34 +02:00
karwostsandBram Kragten f496d842a6 Allow to favorite the manual card (#53445) 2026-08-04 12:50:33 +02:00
Petar PetrovandBram Kragten c14d07c845 Stop the statistics graph card editor looping while typing a name (#53434) 2026-08-04 12:50:32 +02:00
Petar PetrovandBram Kragten 8a1f2e86bd Show progress on the Zigbee backup button while the backup is created (#53422) 2026-08-04 12:49:55 +02:00
Bram Kragten 1454f4d081 Bumped version to 20260729.3 2026-07-31 16:44:13 +02:00
Paul BotteinandBram Kragten 1a47c326b2 Fix tile name truncated too early (#53416) 2026-07-31 16:43:50 +02:00
Aidan TimsonandBram Kragten d8e827e08b Fix profile theme link colour (#53415) 2026-07-31 16:43:49 +02:00
John G.andBram Kragten 301c907fa9 Fix typo in Google Home Matter pairing instructions (#53413)
Corrected a typo in the instructions for pairing a Matter device from Google Home with a pairing code.
2026-07-31 16:43:48 +02:00
karwostsandBram Kragten 1ef3bc94d0 Add a few missing logbook csv fields (#53412) 2026-07-31 16:43:47 +02:00
Bram KragtenandSimon Lamon ba0367be2f use defaults provided by core for ports in strings, add url to new lo… (#53407)
* use defaults provided by core for ports in strings, add url to new location after restart

* Apply suggestions from code review

Co-authored-by: Simon Lamon <[email protected]>

---------

Co-authored-by: Simon Lamon <[email protected]>
2026-07-31 16:43:46 +02:00
Bram Kragten c186a31056 Prevent http confirm dialog from getting closed (#53406) 2026-07-31 16:43:45 +02:00
bf8c92c95e Use backend slugify for the entity ID format preview (#53394)
* Use backend slugify for the entity ID format preview

* Update src/panels/config/core/ha-config-entity-id-format.ts

Co-authored-by: Petar Petrov <[email protected]>

* Use top-level slugify websocket command

* Fix formatting

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-07-31 16:43:44 +02:00
Bram Kragten 9743117abf Bumped version to 20260729.2 2026-07-30 23:27:41 +02:00
Petar PetrovandBram Kragten 5e012973b2 Reject non-http URLs from integrations before using them as links (#53379) 2026-07-30 23:27:10 +02:00
Petar PetrovandBram Kragten ea659f1b33 Show which energy power statistic is missing (#53404) 2026-07-30 23:25:16 +02:00
539803cb5b Search a media player's own library from the media browser (#53402)
* Search a media player's own library from the media browser

The media browser search only ever asked the media sources, so searching
inside a media player's own library (Music Assistant, Sonos, Squeezebox,
Jellyfin) failed. Ask the entity instead when the current item belongs to
it, the same way browsing already does.

* Update src/data/media_source.ts

* Remove blank line in media_source.ts

Removed unnecessary blank line at the top of media_source.ts

---------

Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-07-30 23:25:15 +02:00
karwostsandBram Kragten 944d3332d1 Fix schedule editor dirty tracking (#53401) 2026-07-30 23:25:14 +02:00
Petar PetrovandBram Kragten 4a267d160f Fix numeric input feature editor showing the wrong default style (#53398) 2026-07-30 23:25:13 +02:00
7edb9f8164 Launch screen tweaks (#53380)
* Polish the launch screen

- Use the system sans-serif font so the launch screen no longer blocks on
  loading Roboto
- Reduce the gap between the logo and the loading text
- Dim the loading text to 66% opacity
- Match the OHF logo variant to the applied theme instead of the system
  color scheme
- Make the launch screen text and buttons unselectable

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Drop unit test

* Use classMap

* Drop view transition for css transition to avoid flash

---------

Co-authored-by: marcinbauer85 <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 23:25:12 +02:00
Bram Kragten ff814167c6 Bumped version to 20260729.1 2026-07-30 10:41:32 +02:00
Paul BotteinandBram Kragten d08d8dde64 Show integration logo and name in the replace device dialog (#53384) 2026-07-30 10:41:12 +02:00
Bram Kragten 5da441ceed Strip empty strings from http config (#53377) 2026-07-30 10:41:11 +02:00
Paul BotteinandBram Kragten 22e646a68a Allow area card name to wrap to two lines in vertical layout (#53370)
* Allow area card name to wrap to two lines in vertical layout

* Align vertical tile icons in grid sections

* Cut single big word
2026-07-30 10:41:11 +02:00
d0ab20479f Tools > Template use WA Split Panel (#53021)
* Add Web Awesome Split Panel component

* Use SplitPanel for template page. Add vertical/horizontal option. Cleanup, use flexbox.

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-07-30 10:41:10 +02:00
129 changed files with 6798 additions and 2517 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body
- `yarn lint` passes when practical for the scope.
- `yarn test` or focused relevant tests are green when practical for the scope.
- Tests are added or updated for new data processing and utilities where applicable.
- Each test added by the change protects real logic, not the look of a component.
- User-facing text is localized and follows `ha-frontend-user-facing-text` guidance.
- Components handle loading, error, unavailable, and missing-entity states.
- Entity existence is checked before property access.
+6 -5
View File
@@ -49,12 +49,13 @@ Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`;
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
## Unit And Utility Tests
## When To Add Tests
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
- Mock WebSocket connections and API calls at boundaries.
- Cover loading, error, unavailable, and missing-entity states where relevant.
- Test accessibility-sensitive behavior when it can be asserted without brittle DOM internals.
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
## Dev Servers
@@ -0,0 +1,47 @@
name: Sync device class constants
# Mirrors the device class constants for Home Assistant Core's into the
# build-time default in src/data/devce_classes.ts and opens a PR
# when it drifts. Reads homeassistant/generated/device_classes.json from core.
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *" # Daily, 04:00 UTC
permissions:
contents: read
jobs:
sync:
name: Sync
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Regenerate device class constants
run: ./script/gen_device_classes
- name: Format
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
- name: Create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
branch: chore/sync-numeric-device-classes
commit-message: Update numeric sensor device classes
title: Update numeric sensor device classes
body: |
Regenerated `SENSOR_NUMERIC_DEVICE_CLASSES` from Home Assistant Core's
`SensorDeviceClass`.
Automated by `.github/workflows/sync-numeric-device-classes.yaml`.
@@ -1,7 +1,8 @@
name: Sync numeric device classes
name: Sync sensor entity constants
# Mirrors Home Assistant Core's numeric `SensorDeviceClass` list into the
# build-time default in src/data/sensor_numeric_device_classes.ts and opens a PR
# Mirrors Home Assistant Core's numeric `SensorDeviceClass`, `SensorStateClass`,
# units and related device and state classes arrays into the
# build-time default in src/data/sensor_entity_constants.ts and opens a PR
# when it drifts. Reads homeassistant/generated/sensor.json from core.
on:
@@ -28,8 +29,8 @@ jobs:
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Regenerate numeric device classes
run: ./script/gen_numeric_device_classes
- name: Regenerate sensor entity constants
run: ./script/gen_sensor_entity_constants
- name: Format
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
+1
View File
@@ -33,6 +33,7 @@ Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives fi
- Do not query or manipulate DOM manually when Lit decorators, component refs, or render state are appropriate.
- Scope styles to components, use theme custom properties, and keep layouts mobile-first and RTL-safe.
- All user-facing text must be localized through the translation system.
- Do not write tests just because you changed some code. Write a test when there is real logic that could break without anyone noticing, and explain what the test protects.
## Project Skills
+40
View File
@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/homeassistant/generated/device_classes.json";
const TARGET = join(paths.root_dir, "src", "data", "device_classes.ts");
gulp.task("gen-device-classes", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const domainDeviceClasses = data ?? {};
if (!Object.keys(domainDeviceClasses).length) {
throw new Error(`No device classes found in ${SOURCE_URL}`);
}
const content = `// This file is auto-generated from Home Assistant Core's
// entity platform device classes. Do not edit by hand.
// Regenerate with \`script/gen_device_classes\`.
export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
${Object.entries(domainDeviceClasses)
.map(
([domain, deviceClasses]) =>
` "${domain}": [${deviceClasses.map((deviceClass) => `"${deviceClass}"`).join(", ")}],`
)
.join("\n")}
};
`;
await writeFile(TARGET, content);
});
@@ -1,40 +0,0 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
const TARGET = join(
paths.root_dir,
"src",
"data",
"sensor_numeric_device_classes.ts"
);
gulp.task("gen-numeric-device-classes", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const classes = [...(data.numeric_device_classes ?? [])].sort();
if (!classes.length) {
throw new Error(`No numeric_device_classes found in ${SOURCE_URL}`);
}
const content = `// This file is auto-generated from Home Assistant Core's \`SensorDeviceClass\`
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
// Regenerate with \`script/gen_numeric_device_classes\`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
${classes.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
];
`;
await writeFile(TARGET, content);
});
@@ -0,0 +1,83 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
const TARGET = join(
paths.root_dir,
"src",
"data",
"sensor_entity_constants.ts"
);
gulp.task("gen-sensor-entity-constants", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const numericDeviceClasses = [...(data.numeric_device_classes ?? [])].sort();
const deviceClassUnits = data.device_class_units ?? {};
const convertibleClassUnits = data.convertible_units ?? {};
const stateClasses = [...(data.state_classes ?? [])].sort();
const stateClassUnits = data.state_class_units ?? {};
if (
!numericDeviceClasses.length ||
!stateClasses.length ||
!Object.keys(deviceClassUnits).length ||
!Object.keys(stateClassUnits).length
) {
throw new Error(
`No sensor device classes, state classes or units found in ${SOURCE_URL}`
);
}
const content = `// This file is auto-generated from Home Assistant Core's \`DEVICE_CLASS_UNITS\`
// and \`STATE_CLASS_UNITS\`) and \`SensorDeviceClass\`
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
// Regenerate with \`script/gen_sensor_entity_constants\`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
${numericDeviceClasses.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
];
export const SENSOR_DEVICE_CLASS_UNITS: Record<string, (string | null)[]> = {
${Object.entries(deviceClassUnits)
.map(
([deviceClass, units]) =>
` ${deviceClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
)
.join("\n")}
};
export const SENSOR_DEVICE_CLASS_CONVERTIBLE_UNITS: Record<string, (string | null)[]> = {
${Object.entries(convertibleClassUnits)
.map(
([deviceClass, units]) =>
` ${deviceClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
)
.join("\n")}
};
export const SENSOR_STATE_CLASSES: string[] = [
${stateClasses.map((stateClass) => ` "${stateClass}",`).join("\n")}
];
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
${Object.entries(stateClassUnits)
.map(
([stateClass, units]) =>
` ${stateClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
)
.join("\n")}
};
`;
await writeFile(TARGET, content);
});
+2 -1
View File
@@ -9,8 +9,9 @@ import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
import "./gather-static.js";
import "./gen-device-classes.js";
import "./gen-icons-json.js";
import "./gen-numeric-device-classes.js";
import "./gen-sensor-entity-constants.js";
import "./landing-page.js";
import "./locale-data.js";
import "./rspack.js";
+1
View File
@@ -19,6 +19,7 @@ const baseEntry = {
pref_disable_polling: false,
disabled_by: null,
reason: null,
error_reason_translation_domain: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
};
@@ -424,6 +424,23 @@ const SCHEMAS: {
},
},
},
device_class: {
name: "Device Class",
selector: {
device_class: {
domain: "sensor",
},
},
},
device_class_multiple: {
name: "Device Class (Multiple)",
selector: {
device_class: {
domain: "binary_sensor",
multiple: true,
},
},
},
select_custom: {
name: "Select (Custom)",
selector: {
@@ -39,6 +39,7 @@ const createConfigEntry = (
pref_disable_new_entities: false,
pref_disable_polling: false,
reason: null,
error_reason_translation_domain: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
...override,
+6 -6
View File
@@ -94,7 +94,7 @@
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.50.0",
"cropperjs": "1.6.2",
"cropperjs": "1.6.3",
"culori": "4.0.2",
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
@@ -143,7 +143,7 @@
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.1",
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.65.0",
@@ -152,9 +152,9 @@
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.2",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rspack/core": "2.1.10",
"@rspack/dev-server": "2.2.0",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -165,7 +165,7 @@
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.4",
"@types/luxon": "3.7.5",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
@@ -176,7 +176,7 @@
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.8.1",
"eslint": "10.9.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260729.0"
version = "20260826.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -8,4 +8,4 @@ set -eu -o pipefail
cd "$(dirname "$0")/.."
./node_modules/.bin/gulp gen-numeric-device-classes
./node_modules/.bin/gulp gen-device-classes
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Safe bash settings
# -e Exit on command fail
# -u Exit on unset variable
# -o pipefail Exit if piped command has error code
set -eu -o pipefail
cd "$(dirname "$0")/.."
./node_modules/.bin/gulp gen-sensor-entity-constants
+4 -1
View File
@@ -33,7 +33,10 @@ const normalizeFilterArray = <T>(
};
export const generateEntityFilter = (
hass: HomeAssistant,
hass: Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>,
filter: EntityFilter
): EntityFilterFunc => {
const domains = filter.domain
+3
View File
@@ -304,6 +304,9 @@ export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
swing_mode: "swing_modes",
swing_horizontal_mode: "swing_horizontal_modes",
},
cover: {
speed: "supported_speeds",
},
event: {
event_type: "event_types",
},
+10 -7
View File
@@ -55,17 +55,20 @@ export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
}
const resultPromise = func(hass, ...args);
anyHass[cacheKey] = resultPromise;
const cachePromise = resultPromise.then((result) => ({
result,
cacheKey: generateCacheKey?.(hass, result),
}));
anyHass[cacheKey] = cachePromise;
resultPromise.then(
cachePromise.then(
// When successful, set timer to clear cache
(result) => {
anyHass[cacheKey] = {
result,
cacheKey: generateCacheKey?.(hass, result),
};
anyHass[cacheKey] = result;
setTimeout(() => {
anyHass[cacheKey] = undefined;
if (anyHass[cacheKey] === result) {
anyHass[cacheKey] = undefined;
}
}, cacheTime);
},
// On failure, clear cache right away
+104 -17
View File
@@ -27,26 +27,57 @@ const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
// lead nowhere.
const MIN_NAVIGABLE_POINTS = 2;
// Mirrors the extension's own reading of a point: it takes `value` as [x, y] and
// drops anything whose y is not a real number. That rejects gap-only series, and
// also value-first pairs like the energy device charts' [amount, "sensor.foo"].
// Counts no further than `limit` so this stays cheap on charts with many points.
const itemValues = (raw: unknown): unknown[] | null => {
if (Array.isArray(raw)) {
return raw;
}
if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
if (Array.isArray(value)) {
return value;
}
}
return null;
};
// ECharts spells empty values and numbers as strings too, and neither names a
// category.
const NON_CATEGORY_STRINGS = new Set(["-", "NaN", "null", "undefined"]);
const isCategoryKey = (value: unknown): boolean =>
typeof value === "string" &&
!NON_CATEGORY_STRINGS.has(value) &&
Number.isNaN(Number(value));
// A chart with the value axis on x, like the energy device charts, encodes its
// items value-first: [amount, "sensor.foo"]. The extension only reads a series
// that way when every item has that shape, so mirror the same gate.
const isValueFirstSeries = (data: readonly unknown[]): boolean =>
data.length > 0 &&
data.every((raw) => {
const values = itemValues(raw);
return (
!!values && typeof values[0] === "number" && isCategoryKey(values[1])
);
});
// Mirrors the extension's own reading of a point: it takes `value` as [x, y]
// (or [y, category] in a value-first series) and drops anything whose y is not
// a real number, which rejects gap-only series. Counts no further than `limit`
// so this stays cheap on charts with many points.
const countNumericPoints = (data: unknown, limit: number): number => {
if (!Array.isArray(data)) {
return 0;
}
const valueFirst = isValueFirstSeries(data);
let found = 0;
for (const raw of data) {
let y: unknown = raw;
if (Array.isArray(raw)) {
y = raw.length > 1 ? raw[1] : raw[0];
const values = itemValues(raw);
if (values) {
y = valueFirst ? values[0] : values.length > 1 ? values[1] : values[0];
} else if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
y = Array.isArray(value)
? value.length > 1
? value[1]
: value[0]
: value;
y = (raw as { value?: unknown }).value;
}
if (typeof y === "number" && !Number.isNaN(y)) {
found += 1;
@@ -95,6 +126,10 @@ interface SonifyChartOptions {
localize: LocalizeFunc;
locale: FrontendLocaleData;
config: HassConfig;
// Maps a category key or item name to what should be announced for it, so
// cards that key their data on ids (like the energy device charts) can have
// the display names read out instead. Returning undefined keeps the original.
formatLabel?: (label: string) => string | undefined;
onError: (error: string) => void;
}
@@ -163,6 +198,47 @@ const appendSonificationStyles = () => {
document.head.append(style);
};
// Rebuilds the labels the extension would announce — the category axis's data,
// or the item names on pies, which ignore whatever vestigial axes the chart
// options carry — with each one run through the card's formatter. Returns
// undefined when there is nothing to reword, so the extension's own labels
// stay untouched.
const buildValueLabels = (
categoryAxis: { type?: string; data?: unknown } | undefined,
firstSeries: { type?: string; data?: unknown } | undefined,
formatLabel?: (label: string) => string | undefined
): string[] | undefined => {
if (!formatLabel) {
return undefined;
}
const axisData =
categoryAxis?.type === "category" &&
Array.isArray(categoryAxis.data) &&
categoryAxis.data.length
? categoryAxis.data
: undefined;
const labels = axisData
? axisData.map((entry) =>
entry && typeof entry === "object"
? String((entry as { value?: unknown }).value ?? "")
: String(entry ?? "")
)
: firstSeries?.type === "pie" && Array.isArray(firstSeries.data)
? firstSeries.data.map((raw) => {
const name = (raw as { name?: unknown } | null)?.name;
if (typeof name === "string") {
return name;
}
const values = itemValues(raw);
return values && isCategoryKey(values[1]) ? String(values[1]) : "";
})
: undefined;
if (!labels?.length || labels.every((label) => !label)) {
return undefined;
}
return labels.map((label) => formatLabel(label) ?? label);
};
export const sonifyChart = async (
chart: EChartsType,
options: SonifyChartOptions
@@ -179,8 +255,8 @@ export const sonifyChart = async (
if (!chartOptions) {
return null;
}
const xAxis = ensureArray(chartOptions.xAxis)[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)[0] as YAXisOption | undefined;
const xAxis = ensureArray(chartOptions.xAxis)?.[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)?.[0] as YAXisOption | undefined;
// Chart2Music throws while validating a group with no points, which is what
// placeholder, legend-hidden and all-null series turn into, so only offer it
@@ -196,16 +272,25 @@ export const sonifyChart = async (
const seriesIndex = readable.map((s) => allSeries.indexOf(s));
// Chart2Music always reads out an axis label, and the extension picks the wrong
// axis to name when there is no category axis, so label both explicitly.
// axis to name when there is no category axis, so label both explicitly. On a
// horizontal chart the announced x is the category from the y axis and the
// announced y is the value from the x axis, so the sources swap.
const isTimeAxis = xAxis?.type === "time";
const isHorizontal = xAxis?.type === "value" && yAxis?.type === "category";
const valueLabels = buildValueLabels(
isHorizontal ? yAxis : xAxis,
readable[0],
options.formatLabel
);
const x = {
label:
xAxis?.name ||
(isHorizontal ? yAxis?.name : xAxis?.name) ||
localize(
isTimeAxis
? "ui.components.history_charts.time"
: "ui.components.history_charts.category"
),
...(valueLabels ? { valueLabels } : {}),
// Time series carry raw timestamps, which would otherwise be announced as
// epoch milliseconds.
format: isTimeAxis
@@ -213,7 +298,9 @@ export const sonifyChart = async (
: undefined,
};
const y = {
label: yAxis?.name || localize("ui.components.history_charts.value"),
label:
(isHorizontal ? xAxis?.name : yAxis?.name) ||
localize("ui.components.history_charts.value"),
};
let connection: ReturnType<typeof connect>;
+79 -18
View File
@@ -10,12 +10,16 @@ interface MeanFrame {
}
interface MinMaxFrame {
// A frame can hold a gap marker before any value lands in it, so the min/max
// slots below only mean something once this is true.
hasValue: boolean;
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
gapPoint: Point | undefined;
}
const SECOND = 1000;
@@ -49,6 +53,25 @@ function snapFrameSize(step: number): number {
return snapped;
}
// y is NaN for a frame seeded by a gap marker, which has no value yet.
function newFrame(
point: Point,
x: number,
y: number,
gapPoint: Point | undefined
): MinMaxFrame {
return {
hasValue: gapPoint === undefined,
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
gapPoint,
};
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -82,7 +105,10 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
const rawY = pointData[1] as number | null;
// Number(null) is 0, which would drag the mean towards zero
if (rawY === null) continue;
const y = Number(rawY);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
@@ -120,21 +146,34 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
if (isNaN(x)) continue;
const rawY = pointData[1] as number | null;
if (rawY === null) {
// The chart data modules push a null value to break the line where an
// entity was unavailable. Number(null) is 0, so such a marker must stay
// out of the comparisons below, where it would win the minimum slot
// whenever the readings are positive and discard the frame's real
// minimum. One marker per frame is enough to break the line, and keeping
// them all would blow up the output on series that are mostly null. The
// last one wins: where the break lands only depends on which points it
// sits between, not on its own x.
const gapIndex = Math.floor(x / step);
const gapFrame = frames.get(gapIndex);
if (gapFrame) {
gapFrame.gapPoint = point;
} else {
frames.set(gapIndex, newFrame(point, x, NaN, point));
}
continue;
}
const y = Number(rawY);
if (isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
} else {
frames.set(frameIndex, newFrame(point, x, y, undefined));
} else if (frame.hasValue) {
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
@@ -147,18 +186,40 @@ export function downSampleLineData<
frame.maxX = x;
frame.maxY = y;
}
} else {
// the frame held nothing but a marker so far
frame.hasValue = true;
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
if (frame.hasValue) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
if (frame.gapPoint !== undefined) {
// A marker followed by a value in its own frame is a gap that closed
// within one frame, which is about one device pixel: too narrow to show.
// The kept points are exactly min and max, so comparing against the
// later of the two catches that without any work on the ingest path. A
// marker-only frame compares against its own x and always passes.
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
result.push(frame.gapPoint as T);
}
}
}
+6
View File
@@ -117,6 +117,11 @@ export class HaChartBase extends LitElement {
@property({ type: String }) public height?: string;
// Lets cards that key their data on ids have display names announced
// instead when the chart is navigated with Chart2Music.
@property({ attribute: false })
public sonificationLabelFormatter?: (label: string) => string | undefined;
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@@ -583,6 +588,7 @@ export class HaChartBase extends LitElement {
localize: this.hass.localize,
locale: this.hass.locale,
config: this.hass.config,
formatLabel: this.sonificationLabelFormatter,
onError: () => {
// Charts the extension cannot describe stay silent rather than
// dropping an error on someone who only pressed Tab.
@@ -11,8 +11,9 @@ import type { LocalizeFunc } from "../../common/translations/localize";
import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
deviceAutomationExtraConfig,
deviceAutomationsEqual,
deviceAutomationsSimilar,
findEquivalentDeviceAutomation,
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -202,29 +203,40 @@ export abstract class HaDeviceAutomationPicker<
: // No device, clear the list of automations
[];
// If there is no value, or if we have changed the device ID, reset the
// value. When the device changed (for example after replacing a removed
// device), try to keep the same automation type/subtype on the new device
// before falling back to the first available automation.
if (!this.value || this.value.device_id !== this.deviceId) {
const equivalent =
this.value && this.deviceId
? this._automations.find((automation) =>
deviceAutomationsSimilar(automation, this.value!)
)
: undefined;
this._setValue(
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
);
this._updateValueForDevice();
}
this._renderEmpty = true;
await this.updateComplete;
this._renderEmpty = false;
}
// The current value belongs to another device, either because there is no
// value yet or because the device was just changed. Move it to the same
// automation on the new device when there is one, otherwise start over.
private _updateValueForDevice() {
if (this.deviceId && this.value) {
const equivalent = findEquivalentDeviceAutomation(
this._entityReg,
this._automations!,
this.value
);
if (equivalent) {
this._setValue({
...equivalent,
...deviceAutomationExtraConfig(this.value),
});
return;
}
}
this._setValue(
this._automations!.length
? this._automations![0]
: this._createNoAutomation(this.deviceId)
);
}
private _automationChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
@@ -1,34 +1,57 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDelete } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/entity/state-badge";
import "../../../components/ha-icon-button";
import "../../../components/ha-settings-row";
import type { HomeAssistant } from "../../../types";
import { customElement, property, state } from "lit/decorators";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { fireEvent } from "../../common/dom/fire_event";
import "./state-badge";
import "../ha-icon-button";
import "../ha-settings-row";
import {
internationalizationContext,
registriesContext,
} from "../../data/context";
declare global {
interface HASSDomEvents {
"delete-favorite-entity": { index: number };
}
interface HTMLElementTagNameMap {
"home-favorite-entity-list-item": HomeFavoriteEntityListItem;
"ha-favorite-entity-list-item": HaFavoriteEntityListItem;
}
}
@customElement("home-favorite-entity-list-item")
export class HomeFavoriteEntityListItem extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@customElement("ha-favorite-entity-list-item")
export class HaFavoriteEntityListItem extends LitElement {
@property({ attribute: "entity-id" }) public entityId!: string;
@property({ type: Number }) public index = 0;
@state()
@consumeEntityState({ entityIdPath: ["entityId"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
protected render() {
const stateObj = this.hass.states[this.entityId];
const stateObj = this._stateObj;
const { primary, secondary } = stateObj
? computeEntityPickerDisplay(this.hass, stateObj)
? computeEntityPickerDisplay(
{
...this._registries,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
stateObj
)
: { primary: this.entityId, secondary: undefined };
return html`
@@ -42,7 +65,7 @@ export class HomeFavoriteEntityListItem extends LitElement {
}
<ha-icon-button
.path=${mdiDelete}
.label=${this.hass.localize("ui.common.delete")}
.label=${this._i18n.localize("ui.common.delete")}
@click=${this._delete}
></ha-icon-button>
</ha-settings-row>
@@ -2,24 +2,28 @@ import { mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import "./home-favorite-entity-list-item";
@customElement("home-favorites-editor")
export class HomeFavoritesEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { HaEntityPicker } from "./ha-entity-picker";
import "./ha-entity-picker";
import "../ha-sortable";
import "../ha-svg-icon";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { ValueChangedEvent } from "../../types";
import "./ha-favorite-entity-list-item";
@customElement("ha-favorites-editor")
export class HaFavoritesEditor extends LitElement {
@property({ attribute: false }) public favorites: string[] = [];
@property() public label?: string;
@property() public helper?: string;
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
protected render() {
return html`
${this.label ? html`<p class="field-label">${this.label}</p>` : nothing}
@@ -27,22 +31,21 @@ export class HomeFavoritesEditor extends LitElement {
this.helper ? html`<p class="field-helper">${this.helper}</p>` : nothing
}
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
<div class="home-list">
<div class="favorites-list">
${repeat(
this.favorites,
(entityId) => entityId,
(entityId, index) => html`
<div class="home-list-item favorite-row">
<div class="favorite-row">
<div class="handle">
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
</div>
<home-favorite-entity-list-item
<ha-favorite-entity-list-item
class="favorite-content"
.hass=${this.hass}
.entityId=${entityId}
.index=${index}
@delete-favorite-entity=${this._remove}
></home-favorite-entity-list-item>
></ha-favorite-entity-list-item>
</div>
`
)}
@@ -50,10 +53,9 @@ export class HomeFavoritesEditor extends LitElement {
</ha-sortable>
<ha-entity-picker
add-button
.addButtonLabel=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
)}
.addButtonLabel=${this.addButtonLabel}
.excludeEntities=${this.favorites}
.entityFilter=${this.entityFilter}
@value-changed=${this._add}
></ha-entity-picker>
`;
@@ -102,7 +104,7 @@ export class HomeFavoritesEditor extends LitElement {
color: var(--secondary-text-color);
font-size: 12px;
}
.home-list {
.favorites-list {
display: flex;
flex-direction: column;
}
@@ -131,6 +133,6 @@ export class HomeFavoritesEditor extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"home-favorites-editor": HomeFavoritesEditor;
"ha-favorites-editor": HaFavoritesEditor;
}
}
+3 -3
View File
@@ -101,7 +101,7 @@ export class HaControlSelect extends LitElement {
private _handleOptionClick(ev: MouseEvent) {
if (this.disabled) return;
const value = (ev.target as any).value;
const value = (ev.currentTarget as any).value;
this.value = value;
fireEvent(this, "value-changed", { value });
}
@@ -109,7 +109,7 @@ export class HaControlSelect extends LitElement {
private _handleOptionMouseDown(ev: MouseEvent) {
if (this.disabled) return;
ev.preventDefault();
const value = (ev.target as any).value;
const value = (ev.currentTarget as any).value;
this._activeIndex = this.options?.findIndex(
(option) => option.value === value
);
@@ -121,7 +121,7 @@ export class HaControlSelect extends LitElement {
private _handleOptionFocus(ev: FocusEvent) {
if (this.disabled) return;
const value = (ev.target as any).value;
const value = (ev.currentTarget as any).value;
this._activeIndex = this.options?.findIndex(
(option) => option.value === value
);
+216
View File
@@ -0,0 +1,216 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import { DOMAIN_DEVICE_CLASSES } from "../data/device_classes";
import { computeDeviceClassName } from "../data/entity/device_class";
import type {
HomeAssistantInternationalization,
ValueChangedEvent,
} from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
export const getDeviceClassOptions = (
domain: string,
localize: LocalizeFunc
): PickerComboBoxItem[] =>
(DOMAIN_DEVICE_CLASSES[domain] ?? []).map((deviceClass) => {
const primary = computeDeviceClassName(localize, domain, deviceClass);
return { id: deviceClass, primary, sorting_label: primary };
});
@customElement("ha-device-class-picker")
export class HaDeviceClassPicker extends LitElement {
@property() public domain?: string;
@property({ attribute: false }) public value?: string | string[];
@property({ type: Boolean }) public multiple = false;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
private _loadedDomain?: string;
protected willUpdate() {
if (!this.domain || !this._i18n || this._loadedDomain === this.domain) {
return;
}
this._loadedDomain = this.domain;
this._i18n.loadBackendTranslation("entity_component", this.domain);
}
private get _value(): string[] {
return this.value ? ensureArray(this.value) : [];
}
private _deviceClassName(deviceClass: string): string {
return this._i18n && this.domain
? computeDeviceClassName(this._i18n.localize, this.domain, deviceClass)
: deviceClass;
}
private _options = memoizeOne(
(
domain: string | undefined,
localize: LocalizeFunc | undefined
): PickerComboBoxItem[] =>
domain && localize ? getDeviceClassOptions(domain, localize) : []
);
private _availableOptions = memoizeOne(
(options: PickerComboBoxItem[], selected: string[]) =>
options.filter((option) => !selected.includes(option.id))
);
private _getItems = () => {
const options = this._options(this.domain, this._i18n?.localize);
return this.multiple
? this._availableOptions(options, this._value)
: options;
};
private _valueRenderer = (value: string) =>
html`<span slot="headline">${this._deviceClassName(value)}</span>`;
private _notFoundLabel = (search: string) => {
const term = html`<b>'${search}'</b>`;
return this._i18n
? this._i18n.localize("ui.components.device-class-picker.no_match", {
term,
})
: html`No device classes found for ${term}`;
};
protected render() {
const localize = this._i18n?.localize;
const emptyLabel = localize?.(
"ui.components.device-class-picker.no_device_classes"
);
if (this.multiple) {
const value = this._value;
return html`
${
value.length
? html`
<ha-chip-set>
${repeat(
value,
(deviceClass) => deviceClass,
(deviceClass) => {
const label = this._deviceClassName(deviceClass);
return html`
<ha-input-chip
.item=${deviceClass}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${label}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
`
: nothing
}
<ha-generic-picker
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${""}
.addButtonLabel=${
this.label ?? localize?.("ui.components.device-class-picker.add")
}
.getItems=${this._getItems}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
@value-changed=${this._itemAdded}
></ha-generic-picker>
`;
}
return html`
<ha-generic-picker
.label=${
this.label ??
localize?.("ui.components.device-class-picker.device_class")
}
.value=${this.value as string | undefined}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
@value-changed=${this._valueChanged}
></ha-generic-picker>
`;
}
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || undefined });
}
private _itemAdded(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
const deviceClass = ev.detail.value;
if (!deviceClass || this._value.includes(deviceClass)) {
return;
}
this._setValue([...this._value, deviceClass]);
}
private _removeItem(ev: Event) {
ev.stopPropagation();
const deviceClass = (ev.currentTarget as HTMLElement & { item: string })
.item;
this._setValue(this._value.filter((item) => item !== deviceClass));
}
private _setValue(value: string[]) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
static styles = css`
:host {
display: block;
}
ha-generic-picker {
display: block;
width: 100%;
}
ha-chip-set {
padding: 8px 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-class-picker": HaDeviceClassPicker;
}
}
-287
View File
@@ -1,287 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
interface DeviceClassItem {
deviceClass: string;
domain: string;
name: string;
}
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-device-classes.caption")}
${
this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
),
(item) => item.deviceClass,
(item) =>
html`<ha-check-list-item
.value=${item.deviceClass}
.selected=${(this.value || []).includes(item.deviceClass)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${item.domain}
.deviceClass=${item.deviceClass}
.state=${item.domain === "binary_sensor" ? "on" : undefined}
></ha-domain-icon>
${item.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
`;
}
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined
): DeviceClassItem[] =>
this._deviceClassItems(this._deviceClassDomains(states), localize)
.filter(
(item) =>
!filter ||
item.deviceClass.toLowerCase().includes(filter) ||
item.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _deviceClassDomains = memoizeOne(
(states: ContextType<typeof statesContext>): Map<string, string[]> => {
const domains = new Map<string, string[]>();
Object.values(states).forEach((stateObj) => {
const deviceClass = stateObj.attributes.device_class;
if (!deviceClass) {
return;
}
const domain = computeStateDomain(stateObj);
const known = domains.get(deviceClass);
if (!known) {
domains.set(deviceClass, [domain]);
} else if (!known.includes(domain)) {
known.push(domain);
}
});
return domains;
}
);
private _deviceClassItems = memoizeOne(
(
deviceClassDomains: Map<string, string[]>,
localize: LocalizeFunc
): DeviceClassItem[] =>
[...deviceClassDomains].map(([deviceClass, domains]) => {
for (const domain of domains) {
const name = localize(
`component.${domain}.entity_component.${deviceClass}.name`
);
if (name) {
return { deviceClass, domain, name };
}
}
return { deviceClass, domain: domains[0], name: deviceClass };
}),
([domainsA, localizeA], [domainsB, localizeB]) =>
localizeA === localizeB &&
domainsA.size === domainsB.size &&
[...domainsA].every(
([deviceClass, domains]) =>
domainsB.get(deviceClass)?.join() === domains.join()
)
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev: HASSDomEvent<{ expanded: boolean }>) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev: HASSDomEvent<{ expanded: boolean }>) {
this.expanded = ev.detail.expanded;
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const deviceClasses = this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
);
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
const preserved = (this.value || []).filter((d) => !visible.has(d));
const selected = [...ev.detail.index]
.map((i) => deviceClasses[i]?.deviceClass)
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
+519
View File
@@ -0,0 +1,519 @@
import { consume, type ContextType } from "@lit/context";
import {
mdiChevronDown,
mdiChevronUp,
mdiFilterVariantRemove,
mdiShape,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues } 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 { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { computeRTL } from "../common/util/compute_rtl";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import {
computeDeviceClassName,
NO_DEVICE_CLASS,
} from "../data/entity/device_class";
import {
entityTypeKey,
parseEntityType,
usedEntityTypes,
} from "../data/entity/entity_type";
import { domainToName } from "../data/integration";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
// Core picks this one from the battery level, so it has no usable default.
const FIXED_TYPE_ICONS: Record<string, string> = {
"sensor/battery": "mdi:battery",
};
interface TypeRow {
key: string;
domain: string;
deviceClass?: string;
name: string;
deviceClasses?: string[];
expanded?: boolean;
last?: boolean;
}
@customElement("ha-filter-entity-types")
export class HaFilterEntityTypes extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _filter?: string;
@state() private _expandedDomains = new Set<string>();
@query("ha-list-selectable") private _list?: HaListSelectable;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
private _badgeTypes?: Map<string, string[]>;
protected render() {
const count = this.value?.length
? this._count(this.value, this._badgeTypes!)
: 0;
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-entity-types.caption")}
${
count
? html`<div class="badge">${count}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
</ha-expansion-panel>
${this._panel.showContent ? this._renderContent() : nothing}
`;
}
private _renderContent() {
const rows = this._rows(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this._expandedDomains
);
const rtl = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
return html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list-selectable
multi
controlled
aria-label=${this._localize("ui.components.filter-entity-types.caption")}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
rows,
(row) => row.key,
(row) => this._renderRow(row, rtl)
)}
</ha-list-selectable>
</div>`;
}
private _renderRow(row: TypeRow, rtl: boolean) {
const selected = this._isSelected(row);
const expandable = !!row.deviceClasses?.length;
return html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
class=${classMap({ child: !!row.deviceClass, rtl })}
.value=${row.key}
.selected=${selected}
.indeterminate=${!selected && this._isPartiallySelected(row)}
>
${
row.deviceClass
? html`<ha-tree-indicator
slot="start"
.end=${!!row.last}
></ha-tree-indicator>`
: nothing
}
${
row.deviceClass === NO_DEVICE_CLASS
? html`<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>`
: html`<ha-domain-icon
slot="start"
.icon=${FIXED_TYPE_ICONS[row.key]}
.domain=${row.domain}
.deviceClass=${row.deviceClass}
.state=${row.domain === "binary_sensor" ? "on" : undefined}
?brand-fallback=${!row.deviceClass}
></ha-domain-icon>`
}
<span slot="headline">${row.name}</span>
${
expandable
? html`<ha-icon-button
slot="end"
data-domain=${row.domain}
.path=${row.expanded ? mdiChevronUp : mdiChevronDown}
.label=${this._localize(
row.expanded
? "ui.components.filter-entity-types.collapse"
: "ui.components.filter-entity-types.expand"
)}
@click=${this._toggleDomain}
@keydown=${this._handleChevronKeydown}
></ha-icon-button>`
: nothing
}
</ha-list-item-option>
`;
}
private _types = memoizeOne(usedEntityTypes);
// A selected domain counts for the classes it stands for, so that collapsing
// the last one does not drop the count to one.
private _count = memoizeOne(
(value: string[], types: Map<string, string[]>): number =>
value.reduce((count, key) => {
const { domain, deviceClass } = parseEntityType(key);
return (
count +
(deviceClass ? 1 : Math.max(types.get(domain)?.length ?? 0, 1))
);
}, 0)
);
private _rows = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined,
expandedDomains: Set<string>
): TypeRow[] => {
const types = this._types(states);
const domains = [...types.keys()]
.map((domain) => ({ domain, name: domainToName(localize, domain) }))
.sort((a, b) => stringCompare(a.name, b.name, language));
const rows: TypeRow[] = [];
for (const { domain, name } of domains) {
const deviceClasses = types
.get(domain)!
.map((deviceClass) => ({
deviceClass,
name: this._deviceClassName(localize, domain, deviceClass),
}))
.sort((a, b) => {
if (a.deviceClass === NO_DEVICE_CLASS) {
return 1;
}
if (b.deviceClass === NO_DEVICE_CLASS) {
return -1;
}
return stringCompare(a.name, b.name, language);
});
const matchingClasses = deviceClasses.filter((entry) =>
this._matches(filter, entry.deviceClass, entry.name)
);
const domainMatches = this._matches(filter, domain, name);
if (!domainMatches && !matchingClasses.length) {
continue;
}
// Only a search that matched nothing but device classes unfolds them.
const revealed = !!filter && !domainMatches;
const expanded = revealed || expandedDomains.has(domain);
rows.push({
key: domain,
domain,
name,
deviceClasses: deviceClasses.map((entry) => entry.deviceClass),
expanded,
});
if (!deviceClasses.length || !expanded) {
continue;
}
const children = revealed ? matchingClasses : deviceClasses;
children.forEach((entry, index) => {
rows.push({
key: entityTypeKey(domain, entry.deviceClass),
domain,
deviceClass: entry.deviceClass,
name: entry.name,
last: index === children.length - 1,
});
});
}
return rows;
}
);
private _deviceClassName(
localize: LocalizeFunc,
domain: string,
deviceClass: string
): string {
return deviceClass === NO_DEVICE_CLASS
? localize("ui.components.filter-entity-types.no_device_class")
: computeDeviceClassName(localize, domain, deviceClass);
}
private _matches(
filter: string | undefined,
slug: string,
name: string
): boolean {
return (
!filter ||
slug.toLowerCase().includes(filter) ||
name.toLowerCase().includes(filter)
);
}
private _isSelected(row: TypeRow): boolean {
const value = this.value;
if (!value?.length) {
return false;
}
return value.includes(row.domain) || value.includes(row.key);
}
private _isPartiallySelected(row: TypeRow): boolean {
if (row.deviceClass || !this.value?.length) {
return false;
}
return this.value.some(
(key) => parseEntityType(key).domain === row.domain && key !== row.domain
);
}
public willUpdate(changed: PropertyValues<this>) {
super.willUpdate(changed);
// While closed, the badge reuses the classes it last saw rather than
// rescanning every entity on each state change.
if (this._panel.showContent || !this._badgeTypes) {
this._badgeTypes = this._types(this._states);
}
if (changed.has("expanded") && this.expanded) {
this._expandedDomains = new Set(
(this.value ?? [])
.map((key) => parseEntityType(key))
.filter((type) => type.deviceClass)
.map((type) => type.domain)
);
}
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
// The list activates the focused row on Enter and Space, which would select
// the domain instead of expanding it.
private _handleChevronKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
}
}
private _toggleDomain(ev: Event) {
ev.stopPropagation();
const { domain } = (ev.currentTarget as HTMLElement).dataset;
if (!domain) {
return;
}
const expandedDomains = new Set(this._expandedDomains);
if (!expandedDomains.delete(domain)) {
expandedDomains.add(domain);
}
this._expandedDomains = expandedDomains;
}
private _handleItemToggled(ev: CustomEvent<number>) {
// The list indexes its items by registration order, which a search reorders,
// so read the key off the clicked option instead.
const option = this._list?.items[ev.detail] as HaListItemOption | undefined;
const key = option?.value;
if (!key) {
return;
}
const { domain, deviceClass } = parseEntityType(key);
const value = new Set(this.value ?? []);
const siblings = (this._types(this._states).get(domain) ?? []).map(
(entry) => entityTypeKey(domain, entry)
);
// Drops the classes the domain no longer exposes too, so that a stale key
// can never sit next to the domain that covers it.
const selectDomain = () => {
value.forEach((selected) => {
if (parseEntityType(selected).domain === domain) {
value.delete(selected);
}
});
value.add(domain);
};
if (!deviceClass) {
if (!value.delete(domain)) {
selectDomain();
}
} else if (value.delete(domain)) {
siblings.forEach((sibling) => {
if (sibling !== key) {
value.add(sibling);
}
});
} else if (!value.delete(key)) {
value.add(key);
if (siblings.length && siblings.every((sibling) => value.has(sibling))) {
selectDomain();
}
}
this.value = [...value];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
return [
filterPanelStyles,
css`
/* The list scrolls through its own container, not through the host. */
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
/* Keeps a row that carries the chevron as tall as one that does not. */
ha-list-item-option {
--ha-row-item-padding-block: var(--ha-space-2);
}
ha-list-item-option ha-icon-button {
--ha-icon-button-size: 32px;
}
.child::part(base) {
padding-inline-start: 48px;
}
ha-tree-indicator {
width: 56px;
position: absolute;
top: 0px;
left: 0px;
}
.rtl ha-tree-indicator {
right: 0px;
left: initial;
transform: scaleX(-1);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-entity-types": HaFilterEntityTypes;
}
}
@@ -0,0 +1,45 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { DeviceClassSelector } from "../../data/selector";
import "../ha-device-class-picker";
@customElement("ha-selector-device_class")
export class HaDeviceClassSelector extends LitElement {
@property({ attribute: false }) public selector!: DeviceClassSelector;
@property() public value?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`
<ha-device-class-picker
.domain=${this.selector.device_class?.domain}
.value=${this.value}
.multiple=${this.selector.device_class?.multiple ?? false}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-device-class-picker>
`;
}
static styles = css`
ha-device-class-picker {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-device_class": HaDeviceClassSelector;
}
}
@@ -30,6 +30,7 @@ const LOAD_ELEMENTS = {
date: () => import("./ha-selector-date"),
datetime: () => import("./ha-selector-datetime"),
device: () => import("./ha-selector-device"),
device_class: () => import("./ha-selector-device-class"),
duration: () => import("./ha-selector-duration"),
entity: () => import("./ha-selector-entity"),
entity_name: () => import("./ha-selector-entity-name"),
+49 -50
View File
@@ -2,15 +2,15 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import type { DataTableFiltersValue } from "../data/data_table_filters";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import { entityTypeFilterFunc } from "../data/entity/entity_type";
import type { EntitySources } from "../data/entity/entity_sources";
import type { HomeAssistant } from "../types";
import "./ha-filter-device-classes";
import "./ha-filter-domains";
import "./ha-filter-entity-types";
import "./ha-filter-integrations";
import "./ha-target-picker";
@@ -19,8 +19,8 @@ import "./ha-target-picker";
* confused with `EntitySources`, which maps an entity to its integration.
*/
export interface SourceFilters {
domains?: string[];
deviceClasses?: string[];
/** Domains (`sensor`) and domains narrowed to a device class (`sensor/power`). */
types?: string[];
integrations?: string[];
}
@@ -44,38 +44,30 @@ export const countSourceFilters = (filters: SourceFilters): number =>
Object.values(filters).filter((value) => value?.length).length;
/**
* Narrows entity IDs down by the selected filters: an entity is kept when it
* matches every filter that has a selection.
* Matches an entity against the selected filters: it is kept when it matches
* every filter that has a selection. Undefined when nothing is selected.
*/
export const applySourceFilters = (
entityIds: string[],
export const sourceFilterFunc = (
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const domains = filters.domains?.length ? filters.domains : undefined;
const deviceClasses = filters.deviceClasses?.length
? filters.deviceClasses
): ((entityId: string) => boolean) | undefined => {
const matchesType = filters.types?.length
? entityTypeFilterFunc(filters.types, states)
: undefined;
const integrations = filters.integrations?.length
? filters.integrations
: undefined;
if (!domains && !deviceClasses && !integrations) {
return entityIds;
if (!matchesType && !integrations) {
return undefined;
}
return entityIds.filter((entityId) => {
if (domains && !domains.includes(computeDomain(entityId))) {
return (entityId: string) => {
if (matchesType && !matchesType(entityId)) {
return false;
}
if (deviceClasses) {
const deviceClass = states[entityId]?.attributes.device_class;
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
return false;
}
}
if (integrations) {
const integration =
entities[entityId]?.platform ?? entitySources?.[entityId]?.domain;
@@ -84,13 +76,24 @@ export const applySourceFilters = (
}
}
return true;
});
};
};
/** Narrows entity IDs down by the selected filters. */
export const applySourceFilters = (
entityIds: string[],
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const matches = sourceFilterFunc(filters, states, entities, entitySources);
return matches ? entityIds.filter(matches) : entityIds;
};
/**
* Picker for what a page shows: the targets to include, narrowed down by
* domain, device class and integration. Meant to be placed in an
* `ha-filter-pane`.
* entity type and integration. Meant to be placed in an `ha-filter-pane`.
*
* The pages resolve every entity of a target, secondary ones included, so the
* target picker counts them too.
@@ -106,6 +109,8 @@ export class HaSourcesPicker extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: false }) public entitySources?: EntitySources;
/** Explains what the page shows while no target is picked. */
@property() public description?: string;
@@ -129,6 +134,12 @@ export class HaSourcesPicker extends LitElement {
.hass=${this.hass}
.value=${this.value}
.entityFilter=${this.entityFilter}
.activeFilter=${this._activeFilter(
this.filters,
this.hass.states,
this.hass.entities,
this.entitySources
)}
.primaryEntitiesOnly=${false}
.disabled=${this.disabled}
@value-changed=${this._targetsChanged}
@@ -136,18 +147,12 @@ export class HaSourcesPicker extends LitElement {
<div
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
>
<ha-filter-domains
.value=${this.filters.domains}
.expanded=${this._expandedFilter === "domains"}
@data-table-filter-changed=${this._domainsChanged}
@expanded-changed=${this._domainsExpanded}
></ha-filter-domains>
<ha-filter-device-classes
.value=${this.filters.deviceClasses}
.expanded=${this._expandedFilter === "deviceClasses"}
@data-table-filter-changed=${this._deviceClassesChanged}
@expanded-changed=${this._deviceClassesExpanded}
></ha-filter-device-classes>
<ha-filter-entity-types
.value=${this.filters.types}
.expanded=${this._expandedFilter === "types"}
@data-table-filter-changed=${this._typesChanged}
@expanded-changed=${this._typesExpanded}
></ha-filter-entity-types>
<ha-filter-integrations
.value=${this.filters.integrations}
.expanded=${this._expandedFilter === "integrations"}
@@ -158,6 +163,8 @@ export class HaSourcesPicker extends LitElement {
`;
}
private _activeFilter = memoizeOne(sourceFilterFunc);
protected firstUpdated() {
// The filter panels label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
@@ -168,12 +175,8 @@ export class HaSourcesPicker extends LitElement {
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
}
private _domainsChanged(ev: CustomEvent) {
this._filterChanged("domains", ev);
}
private _deviceClassesChanged(ev: CustomEvent) {
this._filterChanged("deviceClasses", ev);
private _typesChanged(ev: CustomEvent) {
this._filterChanged("types", ev);
}
private _integrationsChanged(ev: CustomEvent) {
@@ -191,12 +194,8 @@ export class HaSourcesPicker extends LitElement {
});
}
private _domainsExpanded(ev: CustomEvent) {
this._filterExpanded("domains", ev);
}
private _deviceClassesExpanded(ev: CustomEvent) {
this._filterExpanded("deviceClasses", ev);
private _typesExpanded(ev: CustomEvent) {
this._filterExpanded("types", ev);
}
private _integrationsExpanded(ev: CustomEvent) {
+11
View File
@@ -108,6 +108,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Entities that pass the filters the page currently has on. Narrows the
* counts, unlike `entityFilter`, which says what can be picked at all.
*/
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
@property({ type: Boolean, reflect: true }) public disabled = false;
@state() private _selectedSection?: TargetTypeFloorless;
@@ -286,6 +293,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ entity: entityIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -306,6 +314,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ device: deviceIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -329,6 +338,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -348,6 +358,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ label: labelIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -470,15 +470,19 @@ export class HaMediaPlayerBrowse extends LitElement {
? MediaClassBrowserSettings[currentItem.children_media_class]
: MediaClassBrowserSettings.directory;
const canPickCurrent =
currentItem?.can_play ||
(currentItem && this.accept?.includes("directory"));
return html`
${
currentItem.can_play || showSearch
canPickCurrent || showSearch
? html`
<div
class="header ${classMap({
"no-img": !currentItem.thumbnail,
"no-dialog": !this.dialog,
"search-only": !currentItem.can_play,
"search-only": !canPickCurrent,
})}"
@transitionend=${this._setHeaderHeight}
>
@@ -491,7 +495,7 @@ export class HaMediaPlayerBrowse extends LitElement {
: nothing
}
${
currentItem.can_play
canPickCurrent
? html`<div class="header-content">
${
currentItem.thumbnail
@@ -503,7 +507,7 @@ export class HaMediaPlayerBrowse extends LitElement {
></ha-media-browser-thumbnail>
${
this.narrow &&
currentItem?.can_play &&
canPickCurrent &&
(!this.accept ||
canPlayChildren.has(
currentItem.media_content_id
@@ -546,7 +550,7 @@ export class HaMediaPlayerBrowse extends LitElement {
}
</div>
${
currentItem.can_play &&
canPickCurrent &&
(!currentItem.thumbnail || !this.narrow)
? html`
<ha-button
@@ -118,6 +118,16 @@ class DialogTargetDetails extends LitElement implements HassDialog {
);
};
private _combinedFilter = memoizeOne(
(
entityFilter: HaEntityPickerEntityFilterFunc | undefined,
activeFilter: (entityId: string) => boolean
): HaEntityPickerEntityFilterFunc =>
(stateObj) =>
(!entityFilter || entityFilter(stateObj)) &&
activeFilter(stateObj.entity_id)
);
private _selectorTarget() {
return this._params?.selector?.target || null;
}
@@ -127,6 +137,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return nothing;
}
const { activeFilter } = this._params;
let deviceFilter: HaDevicePickerDeviceFilterFunc | undefined;
let entityFilter: HaEntityPickerEntityFilterFunc | undefined;
let includeDomains: string[] | undefined;
@@ -145,6 +157,10 @@ class DialogTargetDetails extends LitElement implements HassDialog {
primaryEntitiesOnly = this._params.primaryEntitiesOnly;
}
if (activeFilter) {
entityFilter = this._combinedFilter(entityFilter, activeFilter);
}
const waitingForSources =
this._params.selector &&
this._hasIntegration(this._params.selector) &&
@@ -11,6 +11,7 @@ export interface TargetDetailsDialogParams {
selector?: TargetSelector;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
activeFilter?: (entityId: string) => boolean;
includeDomains?: string[];
includeDeviceClasses?: string[];
primaryEntitiesOnly?: boolean;
@@ -34,6 +34,9 @@ export class HaTargetPickerItemGroup extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
/**
* Show only targets with entities from specific domains.
* @type {Array}
@@ -88,6 +91,7 @@ export class HaTargetPickerItemGroup extends LitElement {
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -92,6 +92,13 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Entities that pass the filters the page currently has on. Narrows the
* count, and the target details, but not what the target resolves to.
*/
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
/**
* Show only targets with entities from specific domains.
* @type {Array}
@@ -189,7 +196,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
</div>
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
<span slot="headline">${(canMigrate && replacement?.name) || name}</span>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
@@ -222,12 +229,7 @@ export class HaTargetPickerItemRow extends LitElement {
${
this.expand || !entries.referenced_entities.length
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries.referenced_entities.length,
}
)}
${this._entitiesLabel(entries)}
</span>`
: html`<ha-button
appearance="filled"
@@ -235,12 +237,7 @@ export class HaTargetPickerItemRow extends LitElement {
size="xs"
@click=${this._openDetails}
>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries.referenced_entities.length,
}
)}
${this._entitiesLabel(entries)}
</ha-button>`
}
</div>
@@ -334,6 +331,28 @@ export class HaTargetPickerItemRow extends LitElement {
`;
}
private _entityCounts(entries: ExtractFromTargetResultReferenced) {
const total = entries.referenced_entities.length;
return {
total,
count: this.activeFilter
? entries.referenced_entities.filter(this.activeFilter).length
: total,
};
}
private _entitiesLabel(entries: ExtractFromTargetResultReferenced): string {
const { count, total } = this._entityCounts(entries);
return this.activeFilter
? this.hass.localize(
"ui.components.target-picker.entities_count_filtered",
{ count, total }
)
: this.hass.localize("ui.components.target-picker.entities_count", {
count,
});
}
private _renderEntries() {
const entries = this.parentEntries || this._entries;
@@ -816,6 +835,7 @@ export class HaTargetPickerItemRow extends LitElement {
itemId: this.itemId,
deviceFilter: this.deviceFilter,
entityFilter: this.entityFilter,
activeFilter: this.activeFilter,
includeDomains: this.includeDomains,
includeDeviceClasses: this.includeDeviceClasses,
primaryEntitiesOnly: this.primaryEntitiesOnly,
+189 -56
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -7,8 +8,16 @@ import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_tim
import type { Trigger } from "../../data/automation";
import { migrateAutomationTrigger } from "../../data/automation";
import { describeCondition, describeTrigger } from "../../data/automation_i18n";
import { fullEntitiesContext, labelsContext } from "../../data/context";
import type { ConditionDescriptions } from "../../data/condition";
import {
conditionDescriptionsContext,
fullEntitiesContext,
labelsContext,
manifestsContext,
triggerDescriptionsContext,
} from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { DomainManifestLookup } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { LogbookEntry } from "../../data/logbook";
import { describeAction } from "../../data/script_i18n";
@@ -17,7 +26,12 @@ import type {
ChooseActionTraceStep,
TraceExtended,
} from "../../data/trace";
import type { TargetSelector } from "../../data/selector";
import { getDataFromPath, isTriggerPath } from "../../data/trace";
import type { TriggerDescriptions } from "../../data/trigger";
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
import { getEntityTarget } from "../../panels/config/automation/target/get_entity_target";
import "../../panels/config/automation/target/ha-automation-row-targets";
import "../../panels/logbook/ha-logbook-renderer";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
@@ -67,6 +81,18 @@ export class HaTracePathDetails extends LitElement {
@consume({ context: labelsContext, subscribe: true })
_labelReg!: LabelRegistryEntry[];
@state()
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: DomainManifestLookup;
@state()
@consume({ context: triggerDescriptionsContext, subscribe: true })
private _triggerDescriptions?: TriggerDescriptions;
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions?: ConditionDescriptions;
protected render(): TemplateResult {
return html`
<div class="padded-box trace-info">
@@ -191,51 +217,8 @@ export class HaTracePathDetails extends LitElement {
)}`;
}
const selectedType = this.selected.type;
return html`
${
curPath === this.selected.path
? currentDetail.alias
? html`<h2>${currentDetail.alias}</h2>`
: selectedType === "trigger"
? html`<h2>
${describeTrigger(
migrateAutomationTrigger({
...currentDetail,
}) as Trigger,
this.hass,
this._entityReg
)}
</h2>`
: selectedType === "condition"
? html`<h2>
${describeCondition(
currentDetail,
this.hass,
this._entityReg
)}
</h2>`
: selectedType === "action"
? html`<h2>
${describeAction(
this.hass,
this._entityReg,
currentDetail
)}
</h2>`
: selectedType === "chooseOption"
? html`<h2>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option",
{ number: pathParts[pathParts.length - 1] }
)}
</h2>`
: nothing
: html`<h2>
${curPath.substring(this.selected.path.length + 1)}
</h2>`
}
${this._renderStepHeading(curPath, currentDetail, pathParts)}
${
data.length === 1
? nothing
@@ -246,17 +229,7 @@ export class HaTracePathDetails extends LitElement {
)}
</h3>`
}
${
curPath
.substring(this.selected.path.length + 1)
.includes("condition")
? html`[${describeCondition(
currentDetail,
this.hass,
this._entityReg
)}]<br />`
: nothing
}
${this._renderNestedCondition(curPath, currentDetail)}
${this.hass!.localize(
"ui.panel.config.automation.trace.path.executed",
{
@@ -324,6 +297,130 @@ export class HaTracePathDetails extends LitElement {
return parts;
}
private _renderStepHeading(
curPath: string,
currentDetail: any,
pathParts: string[]
) {
if (curPath !== this.selected.path) {
return html`<div class="heading">
<h2>${curPath.substring(this.selected.path.length + 1)}</h2>
</div>`;
}
const selectedType = this.selected.type;
const description = currentDetail.alias
? currentDetail.alias
: selectedType === "trigger"
? describeTrigger(
migrateAutomationTrigger({ ...currentDetail }) as Trigger,
this.hass,
this._entityReg
)
: selectedType === "condition"
? describeCondition(currentDetail, this.hass, this._entityReg)
: selectedType === "action"
? describeAction(
this.hass,
this._entityReg,
currentDetail,
undefined,
false,
this._manifests
)
: selectedType === "chooseOption"
? this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option",
{ number: pathParts[pathParts.length - 1] }
)
: undefined;
if (description === undefined) {
return nothing;
}
return html`<div class="heading">
<h2>${description}</h2>
${this._renderTargets(currentDetail, selectedType)}
</div>`;
}
private _renderNestedCondition(curPath: string, currentDetail: any) {
if (
!curPath.substring(this.selected.path.length + 1).includes("condition")
) {
return nothing;
}
return html`<div class="nested-condition">
${describeCondition(currentDetail, this.hass, this._entityReg)}
${this._renderTargets(currentDetail, "condition", "s")}
</div>`;
}
private _renderTargets(
config: any,
type: NodeInfo["type"],
size: "s" | "m" = "m"
) {
const target = this._getTarget(config, type);
if (!target) {
return nothing;
}
const targetSpec = this._getTargetSelector(config, type);
return html`<div class="targets">
<ha-automation-row-targets
.target=${target}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.size=${size}
interactive
></ha-automation-row-targets>
</div>`;
}
private _getTargetSelector(
config: any,
type: NodeInfo["type"]
): TargetSelector["target"] | undefined {
if (type === "trigger") {
return this._triggerDescriptions?.[config.trigger]?.target;
}
if (type === "condition") {
return this._conditionDescriptions?.[config.condition]?.target;
}
if (type === "action" && typeof config.action === "string") {
const [domain, service] = config.action.split(".", 2);
return this.hass.services?.[domain]?.[service]?.target;
}
return undefined;
}
private _getTarget(
config: any,
type: NodeInfo["type"]
): HassServiceTarget | undefined {
if (config.target) {
return config.target;
}
if (type === "trigger" || type === "condition") {
const element = type === "trigger" ? config.trigger : config.condition;
if (element === "state" || element === "numeric_state") {
return getEntityTarget(config.entity_id);
}
if (element === "device") {
return getDeviceTarget(config.device_id);
}
return undefined;
}
if (type === "action") {
return config.entity_id
? getEntityTarget(config.entity_id)
: getDeviceTarget(config.device_id);
}
return undefined;
}
private _renderSelectedConfig() {
if (!this.selected?.path) {
return nothing;
@@ -463,6 +560,42 @@ export class HaTracePathDetails extends LitElement {
min-height: 250px;
}
.heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
margin: var(--ha-space-4) 0;
}
.heading h2 {
margin: 0;
}
.heading .targets {
margin-top: 0;
}
.targets {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
margin-top: var(--ha-space-2);
}
.nested-condition {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
margin-bottom: var(--ha-space-2);
}
.nested-condition .targets {
margin-top: 0;
}
pre {
margin: 0;
}
+35 -223
View File
@@ -149,9 +149,6 @@ const formatNumericLimitValue = (
export interface DescribeOptions {
// Skip the user defined alias and describe the underlying config.
ignoreAlias?: boolean;
// Leave the entities out of the sentence, for rows that render them as
// target badges.
hideEntities?: boolean;
}
export const describeTrigger = (
@@ -210,8 +207,7 @@ const tryDescribeTrigger = (
const description = describeLegacyTrigger(
trigger as LegacyTrigger,
hass,
entityRegistry,
options?.hideEntities
entityRegistry
);
if (description) {
@@ -235,8 +231,7 @@ const tryDescribeTrigger = (
const describeLegacyTrigger = (
trigger: LegacyTrigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
hideEntities = false
entityRegistry: EntityRegistryEntry[]
) => {
// Event Trigger
if (trigger.trigger === "event" && trigger.event_type) {
@@ -267,12 +262,7 @@ const describeLegacyTrigger = (
}
// Numeric State Trigger
if (
trigger.trigger === "numeric_state" &&
(trigger.entity_id || hideEntities)
) {
const states = hass.states;
if (trigger.trigger === "numeric_state") {
const stateObj = Array.isArray(trigger.entity_id)
? hass.states[trigger.entity_id[0]]
: (hass.states[trigger.entity_id] as HassEntity | undefined);
@@ -292,82 +282,23 @@ const describeLegacyTrigger = (
? describeDuration(hass.locale, trigger.for)
: undefined;
if (hideEntities) {
const suffix = numericThresholdSuffix(trigger);
if (!suffix) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.label`
);
const suffix = numericThresholdSuffix(trigger);
if (!suffix) {
return hass.localize(`${triggerTranslationBaseKey}.numeric_state.label`);
}
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
{
attribute: attribute,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
{
attribute: attribute,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
}
const entities: string[] = [];
if (Array.isArray(trigger.entity_id)) {
for (const entity of trigger.entity_id.values()) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
} else if (trigger.entity_id) {
entities.push(
states[trigger.entity_id]
? computeStateName(states[trigger.entity_id])
: trigger.entity_id
);
}
if (trigger.above !== undefined && trigger.below !== undefined) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.above-below`,
{
attribute: attribute,
entity: formatListWithOrs(hass.locale, entities),
numberOfEntities: entities.length,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
}
if (trigger.above !== undefined) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.above`,
{
attribute: attribute,
entity: formatListWithOrs(hass.locale, entities),
numberOfEntities: entities.length,
above: formatNumericLimitValue(hass, trigger.above),
duration: duration,
}
);
}
if (trigger.below !== undefined) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.below`,
{
attribute: attribute,
entity: formatListWithOrs(hass.locale, entities),
numberOfEntities: entities.length,
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
}
);
}
// State Trigger
if (trigger.trigger === "state") {
const states = hass.states;
const entityArray: string[] = ensureArray(trigger.entity_id);
const stateObj = hass.states[entityArray?.[0]] as HassEntity | undefined;
@@ -463,39 +394,12 @@ const describeLegacyTrigger = (
duration = describeDuration(hass.locale, trigger.for) ?? "";
}
if (hideEntities) {
return hass.localize(
`${triggerTranslationBaseKey}.state.description.changed`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
anyChange: toChoice === "special" ? "true" : "false",
fromChoice: fromChoice,
fromString: fromString,
toChoice: toChoice,
toString: toString,
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
}
);
}
const entities: string[] = [];
if (entityArray) {
for (const entity of entityArray) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
}
return hass.localize(
`${triggerTranslationBaseKey}.state.description.full`,
`${triggerTranslationBaseKey}.state.description.changed`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
hasEntity: entities.length !== 0 ? "true" : "false",
entity: formatListWithOrs(hass.locale, entities),
anyChange: toChoice === "special" ? "true" : "false",
fromChoice: fromChoice,
fromString: fromString,
toChoice: toChoice,
@@ -1037,8 +941,7 @@ const tryDescribeCondition = (
const description = describeLegacyCondition(
condition as LegacyCondition,
hass,
entityRegistry,
options?.hideEntities
entityRegistry
);
if (description) {
@@ -1064,8 +967,7 @@ const tryDescribeCondition = (
const describeLegacyCondition = (
condition: LegacyCondition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
hideEntities = false
entityRegistry: EntityRegistryEntry[]
) => {
if (condition.condition === "or") {
const conditions = ensureArray(condition.conditions);
@@ -1122,12 +1024,6 @@ const describeLegacyCondition = (
// State Condition
if (condition.condition === "state") {
if (!condition.entity_id && !hideEntities) {
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.no_entity`
);
}
const stateObj = hass.states[
Array.isArray(condition.entity_id)
? condition.entity_id[0]
@@ -1184,51 +1080,14 @@ const describeLegacyCondition = (
duration = describeDuration(hass.locale, condition.for) || "";
}
if (hideEntities) {
if (states.length === 0) {
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
}
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.is`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
states: formatListWithOrs(hass.locale, states),
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
}
);
if (states.length === 0) {
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
}
const entities: string[] = [];
if (Array.isArray(condition.entity_id)) {
for (const entity of condition.entity_id.values()) {
if (hass.states[entity]) {
entities.push(computeStateName(hass.states[entity]) || entity);
}
}
} else if (condition.entity_id) {
entities.push(
hass.states[condition.entity_id]
? computeStateName(hass.states[condition.entity_id])
: condition.entity_id
);
}
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.full`,
`${conditionsTranslationBaseKey}.state.description.is`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
numberOfEntities: entities.length,
// With "any", entities are joined with "or", which takes a singular
// verb in English even for multiple entities ("A or B is ...").
matchAny: condition.match === "any" ? "true" : "false",
entities:
condition.match === "any"
? formatListWithOrs(hass.locale, entities)
: formatListWithAnds(hass.locale, entities),
numberOfStates: states.length,
states: formatListWithOrs(hass.locale, states),
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
@@ -1237,10 +1096,7 @@ const describeLegacyCondition = (
}
// Numeric State Condition
if (
condition.condition === "numeric_state" &&
(condition.entity_id || hideEntities)
) {
if (condition.condition === "numeric_state") {
const entity_ids = condition.entity_id
? ensureArray(condition.entity_id)
: [];
@@ -1257,64 +1113,20 @@ const describeLegacyCondition = (
: condition.attribute
: undefined;
if (hideEntities) {
const suffix = numericThresholdSuffix(condition);
if (!suffix) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.label`
);
const suffix = numericThresholdSuffix(condition);
if (!suffix) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.label`
);
}
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
{
attribute,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
{
attribute,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
);
}
const entity = formatListWithAnds(
hass.locale,
entity_ids.map((id) =>
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
)
);
if (condition.above !== undefined && condition.below !== undefined) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.above-below`,
{
attribute,
entity,
numberOfEntities: entity_ids.length,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
);
}
if (condition.above !== undefined) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.above`,
{
attribute,
entity,
numberOfEntities: entity_ids.length,
above: formatNumericLimitValue(hass, condition.above),
}
);
}
if (condition.below !== undefined) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.below`,
{
attribute,
entity,
numberOfEntities: entity_ids.length,
below: formatNumericLimitValue(hass, condition.below),
}
);
}
}
// Time condition
+38 -5
View File
@@ -3,10 +3,16 @@ import type { HomeAssistant } from "../types";
type StrictConnectionMode = "disabled" | "guard_page" | "drop_connection";
export interface CloudAutoLogin {
email: string;
failed: string | null;
}
interface CloudStatusNotLoggedIn {
logged_in: false;
cloud: "disconnected" | "connecting" | "connected";
http_use_ssl: boolean;
auto_login: CloudAutoLogin | null;
}
export interface CertificateInformation {
@@ -102,15 +108,15 @@ export interface CloudLoginMFA extends CloudLoginBase {
code: string;
}
export type CloudEvent =
| { type: "login" | "logout" | "auto_login_cancelled" }
| { type: "auto_login_failed"; translation_key: string };
export const cloudLogin = ({
hass,
...rest
}: CloudLoginPassword | CloudLoginMFA) =>
hass.callApi<{ success: boolean; cloud_pipeline?: string }>(
"POST",
"cloud/login",
rest
);
hass.callApi<{ success: boolean }>("POST", "cloud/login", rest);
export const cloudLogout = (hass: HomeAssistant) =>
hass.callApi("POST", "cloud/logout");
@@ -130,6 +136,33 @@ export const cloudRegister = (
password,
});
export const cloudRegisterAutoLogin = (
hass: HomeAssistant,
email: string,
password: string
) =>
hass.callApi("POST", "cloud/register_auto_login", {
email,
password,
});
export const attemptCloudAutoLoginNow = (hass: HomeAssistant) =>
hass.callWS({ type: "cloud/attempt_auto_login_now" });
export const resendCloudAutoLoginConfirm = (hass: HomeAssistant) =>
hass.callWS({ type: "cloud/resend_auto_login_confirm" });
export const cancelCloudAutoLogin = (hass: HomeAssistant) =>
hass.callWS({ type: "cloud/cancel_auto_login" });
export const subscribeCloudEvents = (
hass: HomeAssistant,
callback: (event: CloudEvent) => void
) =>
hass.connection.subscribeMessage<CloudEvent>(callback, {
type: "cloud/subscribe_events",
});
export const cloudResendVerification = (hass: HomeAssistant, email: string) =>
hass.callApi("POST", "cloud/resend_confirm", {
email,
+1
View File
@@ -25,6 +25,7 @@ export interface ConfigEntry {
pref_disable_polling: boolean;
disabled_by: "user" | null;
reason: string | null;
error_reason_translation_domain: string | null;
error_reason_translation_key: string | null;
error_reason_translation_placeholders: Record<string, string> | null;
}
+59 -15
View File
@@ -182,26 +182,70 @@ export const deviceAutomationEditorMode = (
: "unknown-device";
};
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
// automation can be matched to the equivalent one on a different device (for
// example when a referenced device was replaced by a split device).
export const deviceAutomationsSimilar = (
// Whether two device automations describe the same kind of automation, meaning
// the same domain, type, subtype and event. Which device and which entity they
// apply to is ignored, so an automation can be matched against the ones another
// device offers.
const deviceAutomationsSameType = (a: DeviceAutomation, b: DeviceAutomation) =>
deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => Object.is(a[property], b[property]));
// Whether two device automations point at the same entity. Both the entity
// registry id and the entity id are accepted as reference, on either side.
const deviceAutomationsSameEntity = (
entityRegistry: EntityRegistryEntry[],
a: DeviceAutomation,
b: DeviceAutomation
) => {
if (typeof a !== typeof b) {
if (!a.entity_id && !b.entity_id) {
return true;
}
if (!a.entity_id || !b.entity_id) {
return false;
}
return deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => {
const inA = property in a;
const inB = property in b;
if (!inA && !inB) {
return true;
}
return Object.is(a[property], b[property]);
});
return (
a.entity_id === b.entity_id ||
compareEntityIdWithEntityRegId(entityRegistry, a.entity_id, b.entity_id)
);
};
// Finds, among the automations a device offers, the one matching the given
// automation, so a device automation can follow its device after the device was
// replaced. A device exposes the same automation type once per entity, so
// matching on the type alone picks an arbitrary entity. Splitting a device
// leaves the entity registry ids untouched, which makes the entity the reliable
// match, the type only serving as a fallback for entity-less automations.
export const findEquivalentDeviceAutomation = <T extends DeviceAutomation>(
entityRegistry: EntityRegistryEntry[],
automations: T[],
automation: DeviceAutomation
): T | undefined => {
const sameType = automations.filter((candidate) =>
deviceAutomationsSameType(candidate, automation)
);
const sameEntity = sameType.find((candidate) =>
deviceAutomationsSameEntity(entityRegistry, candidate, automation)
);
return sameEntity || sameType[0];
};
// Everything the automation list does not return: the extra fields of the
// capabilities schema (`for`, `above`, ...) and the config of the row holding
// the automation (`enabled`, `id`, `alias`, ...).
export const deviceAutomationExtraConfig = <T extends DeviceAutomation>(
automation: T
): Partial<T> => {
const extraConfig: Partial<T> = {};
for (const property in automation) {
if (
property !== "metadata" &&
!deviceAutomationIdentifiers.includes(property)
) {
extraConfig[property] = automation[property];
}
}
return extraConfig;
};
const compareEntityIdWithEntityRegId = (
+180
View File
@@ -0,0 +1,180 @@
// This file is auto-generated from Home Assistant Core's
// entity platform device classes. Do not edit by hand.
// Regenerate with `script/gen_device_classes`.
export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
binary_sensor: [
"battery",
"battery_charging",
"carbon_monoxide",
"cold",
"connectivity",
"door",
"garage_door",
"gas",
"heat",
"light",
"lock",
"moisture",
"motion",
"moving",
"occupancy",
"opening",
"plug",
"power",
"presence",
"problem",
"running",
"safety",
"smoke",
"sound",
"tamper",
"update",
"vibration",
"window",
],
button: ["identify", "restart", "update"],
cover: [
"awning",
"blind",
"curtain",
"damper",
"door",
"garage",
"gate",
"shade",
"shutter",
"window",
],
event: ["button", "doorbell", "motion"],
humidifier: ["dehumidifier", "humidifier"],
infrared: ["emitter", "receiver"],
media_player: ["projector", "receiver", "speaker", "tv"],
number: [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"radon",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
],
sensor: [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"date",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"enum",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"radon",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"timestamp",
"uptime",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
],
switch: ["outlet", "switch"],
update: ["firmware"],
valve: ["gas", "water"],
};
+11
View File
@@ -0,0 +1,11 @@
import type { LocalizeFunc } from "../../common/translations/localize";
export const NO_DEVICE_CLASS = "none";
export const computeDeviceClassName = (
localize: LocalizeFunc,
domain: string,
deviceClass: string
): string =>
localize(`component.${domain}.entity_component.${deviceClass}.name`) ||
deviceClass;
+92
View File
@@ -0,0 +1,92 @@
import { computeDomain } from "../../common/entity/compute_domain";
import type { HomeAssistant } from "../../types";
import { DOMAIN_DEVICE_CLASSES } from "../device_classes";
import { NO_DEVICE_CLASS } from "./device_class";
const SEPARATOR = "/";
export interface EntityType {
domain: string;
deviceClass?: string;
}
export const entityTypeKey = (domain: string, deviceClass?: string): string =>
deviceClass ? `${domain}${SEPARATOR}${deviceClass}` : domain;
export const parseEntityType = (key: string): EntityType => {
const index = key.indexOf(SEPARATOR);
return index === -1
? { domain: key }
: {
domain: key.slice(0, index),
deviceClass: key.slice(index + SEPARATOR.length),
};
};
export const entityTypesNeedStates = (types?: string[]): boolean =>
!!types?.some((key) => key.includes(SEPARATOR));
// A domain worth no split maps to an empty list rather than to its lone bucket.
export const usedEntityTypes = (
states: HomeAssistant["states"]
): Map<string, string[]> => {
const byDomain = new Map<string, Set<string>>();
for (const stateObj of Object.values(states)) {
const domain = computeDomain(stateObj.entity_id);
let classes = byDomain.get(domain);
if (!classes) {
classes = new Set();
byDomain.set(domain, classes);
}
if (domain in DOMAIN_DEVICE_CLASSES) {
classes.add(stateObj.attributes.device_class || NO_DEVICE_CLASS);
}
}
return new Map(
[...byDomain].map(([domain, classes]) => [
domain,
classes.size > 1 ? [...classes] : [],
])
);
};
// Relies on a domain and its device classes never being selected at once.
export const entityTypeFilterFunc = (
types: string[],
states: HomeAssistant["states"]
): ((entityId: string) => boolean) => {
const domains = new Set<string>();
const deviceClasses = new Map<string, Set<string>>();
for (const key of types) {
const { domain, deviceClass } = parseEntityType(key);
if (deviceClass === undefined) {
domains.add(domain);
} else {
let classes = deviceClasses.get(domain);
if (!classes) {
classes = new Set();
deviceClasses.set(domain, classes);
}
classes.add(deviceClass);
}
}
return (entityId: string) => {
const domain = computeDomain(entityId);
if (domains.has(domain)) {
return true;
}
const classes = deviceClasses.get(domain);
if (!classes) {
return false;
}
const stateObj = states[entityId];
if (!stateObj) {
return false;
}
return classes.has(stateObj.attributes.device_class || NO_DEVICE_CLASS);
};
};
+13
View File
@@ -35,6 +35,18 @@ export interface HomeFrontendSystemData {
shortcuts?: ShortcutItem[];
}
export type SecurityAlertSeverity = "alert" | "warning";
export interface SecurityAlertEntityConfig {
entity: string;
severity?: SecurityAlertSeverity;
}
export interface SecurityFrontendSystemData {
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
}
export interface EnergyFrontendSystemData {
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
// hidden. An absent key or array means nothing is hidden (all cards visible),
@@ -51,6 +63,7 @@ declare global {
core: CoreFrontendSystemData;
home: HomeFrontendSystemData;
energy: EnergyFrontendSystemData;
security: SecurityFrontendSystemData;
}
}
+19 -1
View File
@@ -1,3 +1,4 @@
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
@@ -7,7 +8,7 @@ export interface ResolvedMediaSource {
}
export const resolveMediaSource = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
media_content_id: string
) =>
hass.callWS<ResolvedMediaSource>({
@@ -15,6 +16,23 @@ export const resolveMediaSource = (
media_content_id,
});
// Resolved URLs are signed and valid for 24 hours (CONTENT_AUTH_EXPIRY_TIME in
// core). Resolving again returns a different signature, which would defeat the
// browser cache, so reuse the resolved URL for just under its validity.
export const RESOLVE_CACHE_TIME = 23 * 60 * 60 * 1000; // 23 hours
export const resolveMediaSourceWithCache = (
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
media_content_id: string
): Promise<ResolvedMediaSource> =>
timeCacheEntityPromiseFunc(
"_resolvedMediaSource",
RESOLVE_CACHE_TIME,
resolveMediaSource,
hass,
media_content_id
);
export const browseLocalMediaPlayer = (
hass: HomeAssistant,
mediaContentId?: string
+8
View File
@@ -48,6 +48,7 @@ export type Selector =
| DeviceSelector
| FloorSelector
| LegacyDeviceSelector
| DeviceClassSelector
| DurationSelector
| EntitySelector
| EntityNameSelector
@@ -484,6 +485,13 @@ export interface SelectSelector {
} | null;
}
export interface DeviceClassSelector {
device_class: {
domain: string;
multiple?: boolean;
} | null;
}
export interface SelectorSelector {
selector: {} | null;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "./sensor_numeric_device_classes";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "./sensor_entity_constants";
import type { HomeAssistant } from "../types";
export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
+431
View File
@@ -0,0 +1,431 @@
// This file is auto-generated from Home Assistant Core's `DEVICE_CLASS_UNITS`
// and `STATE_CLASS_UNITS`) and `SensorDeviceClass`
// (all values minus `NON_NUMERIC_DEVICE_CLASSES`). Do not edit by hand.
// Regenerate with `script/gen_sensor_entity_constants`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"radon",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
];
export const SENSOR_DEVICE_CLASS_UNITS: Record<string, (string | null)[]> = {
absolute_humidity: ["g/m³", "mg/m³"],
apparent_power: ["kVA", "mVA", "VA"],
aqi: [null],
area: ["ac", "cm²", "ft²", "ha", "in²", "km²", "mi²", "mm²", "m²", "yd²"],
atmospheric_pressure: [
"bar",
"cbar",
"hPa",
"inHg",
"inH₂O",
"kPa",
"mbar",
"mmHg",
"mPa",
"Pa",
"psi",
],
battery: ["%"],
blood_glucose_concentration: ["mg/dL", "mmol/L"],
carbon_monoxide: ["mg/m³", "ppb", "ppm", "μg/m³"],
carbon_dioxide: ["ppm"],
conductivity: ["mS/cm", "S/cm", "μS/cm"],
current: ["A", "mA", "μA"],
data_rate: [
"B/s",
"bit/s",
"GB/s",
"Gbit/s",
"GiB/s",
"kB/s",
"kbit/s",
"KiB/s",
"MB/s",
"Mbit/s",
"MiB/s",
],
data_size: [
"B",
"bit",
"EB",
"EiB",
"GB",
"Gbit",
"GiB",
"kB",
"kbit",
"KiB",
"MB",
"Mbit",
"MiB",
"PB",
"PiB",
"TB",
"TiB",
"YB",
"YiB",
"ZB",
"ZiB",
],
distance: ["cm", "ft", "in", "km", "m", "mi", "mm", "nmi", "yd"],
duration: ["d", "h", "min", "ms", "s", "μs"],
energy: [
"cal",
"Gcal",
"GJ",
"GWh",
"J",
"kcal",
"kJ",
"kWh",
"Mcal",
"MJ",
"MWh",
"mWh",
"TWh",
"Wh",
],
energy_distance: ["km/kWh", "kWh/100km", "mi/kWh", "Wh/km"],
energy_storage: [
"cal",
"Gcal",
"GJ",
"GWh",
"J",
"kcal",
"kJ",
"kWh",
"Mcal",
"MJ",
"MWh",
"mWh",
"TWh",
"Wh",
],
frequency: ["GHz", "Hz", "kHz", "MHz", "mHz"],
gas: ["CCF", "ft³", "L", "MCF", "m³"],
humidity: ["%"],
illuminance: ["lx"],
irradiance: ["BTU/(h⋅ft²)", "W/m²"],
moisture: ["%"],
nitrogen_dioxide: ["ppb", "ppm", "μg/m³"],
nitrogen_monoxide: ["ppb", "μg/m³"],
nitrous_oxide: ["μg/m³"],
ozone: ["ppb", "ppm", "μg/m³"],
ph: [null],
pm1: ["μg/m³"],
pm10: ["μg/m³"],
pm25: ["μg/m³"],
pm4: ["μg/m³"],
power_factor: ["%", null],
power: ["GW", "kW", "MW", "mW", "TW", "W"],
precipitation: ["cm", "in", "mm"],
precipitation_intensity: ["in/d", "in/h", "mm/d", "mm/h"],
pressure: [
"bar",
"cbar",
"hPa",
"inHg",
"inH₂O",
"kPa",
"mbar",
"mmHg",
"mPa",
"Pa",
"psi",
],
radon: ["Bq/m³", "pCi/L"],
reactive_energy: ["kvarh", "varh"],
reactive_power: ["kvar", "mvar", "var"],
signal_strength: ["dB", "dBm"],
sound_pressure: ["dB", "dBA"],
speed: [
"Beaufort",
"ft/s",
"in/d",
"in/h",
"in/s",
"km/h",
"kn",
"m/min",
"m/s",
"mm/d",
"mm/h",
"mm/s",
"mph",
],
sulphur_dioxide: ["ppb", "μg/m³"],
temperature: ["K", "°C", "°F"],
temperature_delta: ["K", "°C", "°F"],
volatile_organic_compounds: ["mg/m³", "μg/m³"],
volatile_organic_compounds_parts: ["ppb", "ppm"],
voltage: ["kV", "MV", "mV", "V", "μV"],
volume: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
volume_flow_rate: [
"ft³/min",
"gal/d",
"gal/h",
"gal/min",
"L/h",
"L/min",
"L/s",
"mL/s",
"m³/h",
"m³/min",
"m³/s",
],
volume_storage: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
water: ["CCF", "ft³", "gal", "L", "MCF", "m³"],
weight: ["g", "kg", "lb", "mg", "oz", "st", "μg"],
wind_direction: ["°"],
wind_speed: [
"Beaufort",
"ft/s",
"in/s",
"km/h",
"kn",
"m/min",
"m/s",
"mm/s",
"mph",
],
};
export const SENSOR_DEVICE_CLASS_CONVERTIBLE_UNITS: Record<
string,
(string | null)[]
> = {
absolute_humidity: ["g/m³", "mg/m³"],
apparent_power: ["kVA", "mVA", "VA"],
area: ["ac", "cm²", "ft²", "ha", "in²", "km²", "mi²", "mm²", "m²", "yd²"],
atmospheric_pressure: [
"bar",
"cbar",
"hPa",
"inHg",
"inH₂O",
"kPa",
"mbar",
"mmHg",
"mPa",
"Pa",
"psi",
],
blood_glucose_concentration: ["mg/dL", "mmol/L"],
carbon_monoxide: ["mg/m³", "ppb", "ppm", "μg/m³"],
conductivity: ["mS/cm", "S/cm", "μS/cm"],
current: ["A", "mA", "μA"],
data_rate: [
"B/s",
"bit/s",
"GB/s",
"Gbit/s",
"GiB/s",
"kB/s",
"kbit/s",
"KiB/s",
"MB/s",
"Mbit/s",
"MiB/s",
],
data_size: [
"B",
"bit",
"EB",
"EiB",
"GB",
"Gbit",
"GiB",
"kB",
"kbit",
"KiB",
"MB",
"Mbit",
"MiB",
"PB",
"PiB",
"TB",
"TiB",
"YB",
"YiB",
"ZB",
"ZiB",
],
distance: ["cm", "ft", "in", "km", "m", "mi", "mm", "nmi", "yd"],
duration: ["d", "h", "min", "ms", "s", "μs"],
energy: [
"cal",
"Gcal",
"GJ",
"GWh",
"J",
"kcal",
"kJ",
"kWh",
"Mcal",
"MJ",
"MWh",
"mWh",
"TWh",
"Wh",
],
energy_distance: ["km/kWh", "kWh/100km", "mi/kWh", "Wh/km"],
energy_storage: [
"cal",
"Gcal",
"GJ",
"GWh",
"J",
"kcal",
"kJ",
"kWh",
"Mcal",
"MJ",
"MWh",
"mWh",
"TWh",
"Wh",
],
frequency: ["GHz", "Hz", "kHz", "MHz", "mHz"],
gas: ["CCF", "ft³", "L", "MCF", "m³"],
nitrogen_dioxide: ["ppb", "ppm", "μg/m³"],
nitrogen_monoxide: ["ppb", "μg/m³"],
ozone: ["ppb", "ppm", "μg/m³"],
power: ["GW", "kW", "MW", "mW", "TW", "W"],
power_factor: ["%", null],
precipitation: ["cm", "in", "mm"],
precipitation_intensity: ["in/d", "in/h", "mm/d", "mm/h"],
pressure: [
"bar",
"cbar",
"hPa",
"inHg",
"inH₂O",
"kPa",
"mbar",
"mmHg",
"mPa",
"Pa",
"psi",
],
radon: ["Bq/m³", "pCi/L"],
reactive_energy: ["kvarh", "varh"],
reactive_power: ["kvar", "mvar", "var"],
speed: [
"Beaufort",
"ft/s",
"in/d",
"in/h",
"in/s",
"km/h",
"kn",
"m/min",
"m/s",
"mm/d",
"mm/h",
"mm/s",
"mph",
],
sulphur_dioxide: ["ppb", "μg/m³"],
temperature: ["K", "°C", "°F"],
temperature_delta: ["K", "°C", "°F"],
volatile_organic_compounds: ["mg/m³", "μg/m³"],
volatile_organic_compounds_parts: ["ppb", "ppm"],
voltage: ["kV", "MV", "mV", "V", "μV"],
volume: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
volume_flow_rate: [
"ft³/min",
"gal/d",
"gal/h",
"gal/min",
"L/h",
"L/min",
"L/s",
"mL/s",
"m³/h",
"m³/min",
"m³/s",
],
volume_storage: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
water: ["CCF", "ft³", "gal", "L", "MCF", "m³"],
weight: ["g", "kg", "lb", "mg", "oz", "st", "μg"],
wind_speed: [
"Beaufort",
"ft/s",
"in/s",
"km/h",
"kn",
"m/min",
"m/s",
"mm/s",
"mph",
],
};
export const SENSOR_STATE_CLASSES: string[] = [
"measurement",
"measurement_angle",
"total",
"total_increasing",
];
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
measurement_angle: ["°"],
};
-64
View File
@@ -1,64 +0,0 @@
// This file is auto-generated from Home Assistant Core's `SensorDeviceClass`
// (all values minus `NON_NUMERIC_DEVICE_CLASSES`). Do not edit by hand.
// Regenerate with `script/gen_numeric_device_classes`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"radon",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
];
+104 -5
View File
@@ -10,6 +10,7 @@ import deepClone from "deep-clone-simple";
import { deepActiveElement } from "../../common/dom/deep-active-element";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
@@ -123,6 +124,7 @@ export class DialogForm
this._initialData = deepClone(this._data);
this._error = undefined;
this._resetDirtyTracking();
void this._focusActiveForm(nested);
};
private _popStack(): StackEntry | undefined {
@@ -142,16 +144,111 @@ export class DialogForm
return prev;
}
private async _afterFormRender(): Promise<void> {
await this.updateComplete;
await this._form?.updateComplete;
await nextRender();
}
private async _waitForSelectorElements(): Promise<void> {
const selectors = this._form?.shadowRoot?.querySelectorAll("ha-selector");
if (selectors?.length) {
await Promise.all(
Array.from(selectors, (element) =>
"updateComplete" in element
? (element as LitElement).updateComplete
: undefined
)
);
}
const pending = this._undefinedCustomElements(this._form);
if (!pending.length) {
return;
}
await Promise.all(pending.map((tag) => customElements.whenDefined(tag)));
await nextRender();
}
private _undefinedCustomElements(root?: ParentNode): string[] {
const tags = new Set<string>();
const visit = (node: ParentNode) => {
if (node instanceof Element && node.shadowRoot) {
visit(node.shadowRoot);
}
for (const child of node.children) {
if (
child.localName.includes("-") &&
!customElements.get(child.localName)
) {
tags.add(child.localName);
}
visit(child);
}
};
if (root) {
visit(root);
}
return [...tags];
}
private _focusFirstControl(root = this._form): void {
if (!root) {
return;
}
const visit = (node: ParentNode): HTMLElement | undefined => {
if (node instanceof Element && node.shadowRoot) {
const inShadow = visit(node.shadowRoot);
if (inShadow) {
return inShadow;
}
}
for (const child of node.children) {
if (
child instanceof HTMLElement &&
child.matches("input, textarea, select, button")
) {
return child;
}
const found = visit(child);
if (found) {
return found;
}
}
return undefined;
};
visit(root)?.focus();
}
private async _focusActiveForm(
expectedParams: FormDialogParams
): Promise<void> {
await this._afterFormRender();
if (!this._open || this._params !== expectedParams) {
return;
}
await this._waitForSelectorElements();
if (!this._open || this._params !== expectedParams) {
return;
}
this._focusFirstControl();
}
private async _restoreFocusAndScroll(
scrollTop: number,
expectedParams: FormDialogParams,
focusTarget?: Element
): Promise<void> {
await this.updateComplete;
await this._form?.updateComplete;
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
await this._afterFormRender();
if (!this._open || this._params !== expectedParams || !this._dialog) {
return;
@@ -159,6 +256,8 @@ export class DialogForm
if (focusTarget instanceof HTMLElement && focusTarget.isConnected) {
focusTarget.focus();
} else {
this._focusFirstControl();
}
this._dialog.bodyContainer.scrollTop = scrollTop;
@@ -73,7 +73,10 @@ class HaMoreInfoDetails extends LitElement {
stateEntries,
attributes,
yamlData: stateYamlData,
} = this._getDetailData(this._stateObj);
} = this._getDetailData(
this._stateObj,
this.hass.formatEntityAttributeName
);
const { floor, area, device } = getEntityContext(
this._stateObj,
this.hass.entities,
@@ -211,7 +214,10 @@ class HaMoreInfoDetails extends LitElement {
private _getDetailData = memoizeOne(
(
stateObj: HassEntity
stateObj: HassEntity,
// cache key only: a new function is assigned when translation-based
// format functions reload, invalidating results formatted via this.hass
_formatEntityAttributeName: HomeAssistant["formatEntityAttributeName"]
): {
stateEntries: DetailEntry[];
attributes: { name: string; label: string }[];
+8 -4
View File
@@ -1029,10 +1029,14 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
}
if (changedProps.has("_currView") || changedProps.has("_entry")) {
if (this._currView === "settings" && this._entry) {
this._initDirtyTracking({ type: "deep" });
}
if (
this._currView === "settings" &&
this._entry &&
((changedProps.has("_currView") &&
changedProps.get("_currView") !== "settings") ||
(changedProps.has("_entry") && !changedProps.get("_entry")))
) {
this._initDirtyTracking({ type: "deep" });
}
if (changedProps.has("_currView")) {
@@ -42,6 +42,24 @@ export const climateEntityFilters: EntityFilter[] = [
},
];
export const hasClimateEntities = (hass: HomeAssistant): boolean => {
const hasAreaSensor = Object.values(hass.areas).some(
(area) =>
(area.temperature_entity_id && hass.states[area.temperature_entity_id]) ||
(area.humidity_entity_id && hass.states[area.humidity_entity_id])
);
if (hasAreaSensor) {
return true;
}
const entityIds = Object.keys(hass.states);
return climateEntityFilters.some((filter) =>
entityIds.some(generateEntityFilter(hass, filter))
);
};
const processAreasForClimate = (
areaIds: string[],
hass: HomeAssistant,
@@ -62,9 +62,11 @@ import type {
AutomationClipboard,
Condition,
} from "../../../../data/automation";
import type { ConditionDescriptions } from "../../../../data/condition";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import { validateConfig } from "../../../../data/config";
import {
conditionDescriptionsContext,
fullEntitiesContext,
manifestsContext,
} from "../../../../data/context";
@@ -90,6 +92,8 @@ import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import "./ha-automation-action-editor";
import type HaAutomationActionEditor from "./ha-automation-action-editor";
@@ -205,6 +209,10 @@ export default class HaAutomationActionRow extends LitElement {
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: DomainManifestLookup;
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions?: ConditionDescriptions;
@state() private _running = false;
@state() private _runResult?: {
@@ -292,13 +300,19 @@ export default class HaAutomationActionRow extends LitElement {
? this._extractTargets(this.action as ServiceAction)
: type === "device_id" && (this.action as DeviceAction).device_id
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
: type === "condition"
? this._extractConditionTarget(this.action as Condition)
: undefined;
const serviceTargetSpec =
type === "service" && action
? this.hass.services?.[computeDomain(action)]?.[computeObjectId(action)]
type === "condition"
? this._conditionDescriptions?.[(this.action as Condition).condition]
?.target
: undefined;
: type === "service" && action
? this.hass.services?.[computeDomain(action)]?.[
computeObjectId(action)
]?.target
: undefined;
const noteTooltipText = truncateWithEllipsis(
this.action.note?.trim() || "",
@@ -777,6 +791,28 @@ export default class HaAutomationActionRow extends LitElement {
return {};
}
private _extractConditionTarget(
condition: Condition
): HassServiceTarget | undefined {
if (typeof condition !== "object") {
return undefined;
}
if ("target" in condition && condition.target) {
return condition.target;
}
if (
(condition.condition === "state" ||
condition.condition === "numeric_state") &&
"entity_id" in condition
) {
return getEntityTarget(condition.entity_id);
}
if (condition.condition === "device" && "device_id" in condition) {
return getDeviceTarget(condition.device_id as string);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
@@ -226,9 +226,7 @@ export default class HaAutomationConditionRow extends LitElement {
}
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg, {
hideEntities: true,
})
describeCondition(this.condition, this.hass, this._entityReg)
)}
${
target !== undefined || targetRequired
@@ -68,6 +68,9 @@ export class HaAutomationRowTargets extends LitElement {
@property({ type: Boolean })
public interactive = false;
@property({ reflect: true })
public size: "s" | "m" = "m";
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -652,6 +655,23 @@ export class HaAutomationRowTargets extends LitElement {
align-items: center;
}
:host([size="s"]) {
min-height: 24px;
}
:host([size="s"]) .target {
height: 24px;
}
/* A default 24px icon would fill the whole small chip. */
:host([size="s"]) .target ha-icon,
:host([size="s"]) .target ha-svg-icon,
:host([size="s"]) .target ha-domain-icon,
:host([size="s"]) .target ha-floor-icon {
--mdc-icon-size: 16px;
}
:host([size="s"]) .target ha-floor-icon {
height: 24px;
}
button.target {
cursor: pointer;
}
@@ -251,9 +251,7 @@ export default class HaAutomationTriggerRow extends LitElement {
}
<h3 slot="header">
${capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg, {
hideEntities: true,
})
describeTrigger(this.trigger, this.hass, this._entityReg)
)}
${
target !== undefined || targetRequired
@@ -21,14 +21,10 @@ import {
completeCloudOnboarding,
fetchCloudSubscriptionInfo,
ONBOARDING_ITEMS,
removeCloudData,
} from "../../../../data/cloud";
import type { Webhook } from "../../../../data/webhook";
import { fetchWebhooks } from "../../../../data/webhook";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { haStyle } from "../../../../resources/styles";
@@ -41,6 +37,7 @@ import {
} from "./cloud-account-status";
import { showCloudOnboardingDialog } from "./show-dialog-cloud-onboarding";
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
import { confirmDeleteCloudData } from "../delete-cloud-data";
@customElement("cloud-account")
export class CloudAccount extends SubscribeMixin(LitElement) {
@@ -297,33 +294,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
}
private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_text"
),
confirmText: this.hass.localize("ui.panel.config.cloud.account.reset"),
destructive: true,
});
if (!confirm) {
return;
}
try {
await cloudLogout(this.hass);
await removeCloudData(this.hass);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_failed"
),
text: err?.message,
});
return;
} finally {
fireEvent(this, "ha-refresh-cloud-status");
}
await confirmDeleteCloudData(this, this.hass, { signOutFirst: true });
}
private async _downloadSupportPackage() {
@@ -0,0 +1,63 @@
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../types";
import { showSupportPackageDialog } from "./account/show-dialog-cloud-support-package";
import { confirmDeleteCloudData } from "./delete-cloud-data";
// Recovery actions for the signed-out cloud pages, which all need to reach them
// — including while a registration is waiting on its email confirmation.
@customElement("cloud-signed-out-menu")
export class CloudSignedOutMenu extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
protected render(): TemplateResult {
return html`
<ha-dropdown @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="reset">
${this.hass.localize("ui.panel.config.cloud.account.reset_cloud_data")}
<ha-svg-icon slot="icon" .path=${mdiDeleteForever}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="download">
${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
`;
}
private _handleMenuAction(ev: HaDropdownSelectEvent) {
switch (ev.detail.item.value) {
case "reset":
this._deleteCloudData();
break;
case "download":
showSupportPackageDialog(this);
break;
}
}
private async _deleteCloudData() {
await confirmDeleteCloudData(this, this.hass);
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-signed-out-menu": CloudSignedOutMenu;
}
}
@@ -0,0 +1,24 @@
import { css } from "lit";
export const cloudSignedOutStyle = css`
.content {
box-sizing: border-box;
display: flex;
flex-direction: column;
padding-bottom: calc(var(--safe-area-inset-bottom) + var(--ha-space-6));
}
ha-card {
width: 100%;
margin-bottom: 0;
}
h2 {
margin: 0;
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
white-space: normal;
overflow: visible;
text-overflow: clip;
text-wrap: balance;
}
`;
@@ -0,0 +1,48 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { cloudLogout, removeCloudData } from "../../../data/cloud";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
// Confirms, then wipes every trace of the cloud account from this instance.
// `signOutFirst` is for callers reached while signed in: the session has to go
// before the data can.
export const confirmDeleteCloudData = async (
element: HTMLElement,
hass: HomeAssistant,
{ signOutFirst = false }: { signOutFirst?: boolean } = {}
): Promise<boolean> => {
const confirm = await showConfirmationDialog(element, {
title: hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_title"
),
text: hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_text"
),
confirmText: hass.localize("ui.panel.config.cloud.account.reset"),
destructive: true,
});
if (!confirm) {
return false;
}
try {
if (signOutFirst) {
await cloudLogout(hass);
}
await removeCloudData(hass);
} catch (err: any) {
showAlertDialog(element, {
title: hass.localize("ui.panel.config.cloud.account.reset_data_failed"),
text: err?.message,
});
return false;
} finally {
fireEvent(element, "ha-refresh-cloud-status");
}
return true;
};
@@ -169,4 +169,8 @@ declare global {
interface HTMLElementTagNameMap {
"cloud-forgot-password-card": CloudForgotPasswordCard;
}
interface HASSDomEvents {
"cloud-done": { flashMessage: string };
}
}
@@ -21,7 +21,7 @@ export class CloudForgotPassword extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
back-path="/config/cloud/login"
.header=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.title"
)}
+58 -5
View File
@@ -1,12 +1,14 @@
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import type { CloudStatus } from "../../../data/cloud";
import { cancelCloudAutoLogin } from "../../../data/cloud";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import type { ValueChangedEvent, HomeAssistant, Route } from "../../../types";
import "./account/cloud-account";
import "./login/cloud-login-panel";
import "./start/cloud-start";
const LOGGED_IN_URLS = [
"account",
@@ -18,7 +20,12 @@ const LOGGED_IN_URLS = [
"webhooks",
] as const;
const NOT_LOGGED_IN_URLS = ["login", "register", "forgot-password"] as const;
const NOT_LOGGED_IN_URLS = [
"start",
"login",
"register",
"forgot-password",
] as const;
type CloudPage =
(typeof LOGGED_IN_URLS)[number] | (typeof NOT_LOGGED_IN_URLS)[number];
@@ -36,7 +43,7 @@ class HaConfigCloud extends HassRouterPage {
@property({ attribute: false }) public cloudStatus!: CloudStatus;
protected routerOptions: RouterOptions = {
defaultPage: "login",
defaultPage: "start",
showLoading: true,
initialLoad: () => this._cloudStatusLoaded,
// Guard the different pages based on if we're logged in.
@@ -45,14 +52,30 @@ class HaConfigCloud extends HassRouterPage {
if (!LOGGED_IN_URLS.some((url) => url === page)) {
return "account";
}
} else if (!NOT_LOGGED_IN_URLS.some((url) => url === page)) {
return "login";
return undefined;
}
if (!NOT_LOGGED_IN_URLS.some((url) => url === page)) {
return "start";
}
if (
page !== "register" &&
this.cloudStatus.auto_login &&
!this._autoLoginCancelled
) {
return "register";
}
return undefined;
},
routes: {
start: {
tag: "cloud-start",
},
login: {
tag: "cloud-login-panel",
load: () => import("./login/cloud-login-panel"),
},
register: {
tag: "cloud-register",
@@ -96,6 +119,10 @@ class HaConfigCloud extends HassRouterPage {
@state() private _loginEmail = "";
private _autoLoginCancelled = false;
private _lastPage = "";
private _resolveCloudStatusLoaded!: () => void;
private _cloudStatusLoaded = new Promise<void>((resolve) => {
@@ -122,6 +149,14 @@ class HaConfigCloud extends HassRouterPage {
navigate(this.route.prefix, { replace: true });
}
}
// A flash belongs to the page it was routed to. Cleared here rather than in
// beforeRender, which runs inside update() where assigning state schedules a
// second cycle.
if (this._lastPage === "login" && this._currentPage !== "login") {
this._flashMessage = "";
}
this._lastPage = this._currentPage;
}
protected createElement(tag: string) {
@@ -132,9 +167,27 @@ class HaConfigCloud extends HassRouterPage {
el.addEventListener("flash-message-changed", (ev) => {
this._flashMessage = (ev as ValueChangedEvent<string>).detail.value;
});
el.addEventListener("cloud-cancel-auto-login", () => {
this._autoLoginCancelled = true;
this._cancelAutoLogin();
});
el.addEventListener("cloud-auto-login-started", () => {
this._autoLoginCancelled = false;
});
return el;
}
private async _cancelAutoLogin() {
try {
await cancelCloudAutoLogin(this.hass);
} catch (_err) {
// Both callers are fire-and-forget navigations with nowhere to report
// this; the refresh below still reconciles whatever the backend kept.
} finally {
fireEvent(this, "ha-refresh-cloud-status");
}
}
protected updatePageEl(el) {
// We are not going to update if the current page if we are not logged in
// and the current page requires being logged in. Happens when we log out.
@@ -1,28 +1,14 @@
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { navigate } from "../../../../common/navigate";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list";
import "../../../../components/ha-list-item";
import "../../../../components/ha-svg-icon";
import { removeCloudData } from "../../../../data/cloud";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
import "./cloud-login";
import type { CloudLogin } from "./cloud-login";
@@ -30,212 +16,88 @@ import type { CloudLogin } from "./cloud-login";
export class CloudLoginPanel extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property() public email?: string;
@property({ attribute: false }) public flashMessage?: string;
@query("cloud-login") private _cloudLoginElement!: CloudLogin;
@query("cloud-login") private _cloudLoginElement?: CloudLogin;
protected firstUpdated(): void {
this._focusEmail();
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
header="Home Assistant Cloud"
back-path="/config/cloud/start"
.header=${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
>
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="reset">
${this.hass.localize(
"ui.panel.config.cloud.account.reset_cloud_data"
)}
<ha-svg-icon slot="icon" .path=${mdiDeleteForever}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="download">
${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
<span slot="header">Home Assistant Cloud</span>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction2"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
${
this.flashMessage
? html`<ha-alert
dismissable
@alert-dismissed-clicked=${this._dismissFlash}
>
Nabu&nbsp;Casa,&nbsp;Inc</a
>${this.hass.localize(
"ui.panel.config.cloud.login.introduction2a"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction3"
)}
</p>
<p>
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.login.learn_more_link"
)}
</a>
</p>
</div>
${
this.flashMessage
? html`<ha-alert
dismissable
@alert-dismissed-clicked=${this._dismissFlash}
>
${this.flashMessage}
</ha-alert>`
: ""
}
<cloud-login
.hass=${this.hass}
.email=${this.email}
.localize=${this.hass.localize}
@cloud-forgot-password=${this._handleForgotPassword}
check-connection
></cloud-login>
<ha-card outlined>
<ha-list>
<ha-list-item @click=${this._handleRegister} twoline hasMeta>
${this.hass.localize(
"ui.panel.config.cloud.login.start_trial"
)}
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.cloud.login.trial_info"
)}
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</ha-list>
</ha-card>
</ha-config-section>
${this.flashMessage}
</ha-alert>`
: nothing
}
<cloud-login
.hass=${this.hass}
.email=${this.email}
.localize=${this.hass.localize}
.lead=${this.hass.localize(
"ui.panel.config.cloud.login.sign_in_lead"
)}
check-connection
@cloud-forgot-password=${this._handleForgotPassword}
></cloud-login>
</div>
</hass-subpage>
`;
}
private async _focusEmail() {
const cloudLogin = this._cloudLoginElement;
if (!cloudLogin) {
return;
}
await cloudLogin.updateComplete;
cloudLogin.emailField?.focus();
}
private _handleForgotPassword() {
this._dismissFlash();
fireEvent(this, "cloud-email-changed", {
value: this._cloudLoginElement.emailField.value ?? "",
value: this._cloudLoginElement?.emailField?.value ?? this.email ?? "",
});
navigate("/config/cloud/forgot-password");
}
private _handleRegister() {
this._dismissFlash();
fireEvent(this, "cloud-email-changed", {
value: this._cloudLoginElement.emailField.value ?? "",
});
navigate("/config/cloud/register");
}
private _dismissFlash() {
fireEvent(this, "flash-message-changed", { value: "" });
}
private _handleMenuAction(ev: HaDropdownSelectEvent) {
const value = ev.detail.item.value;
switch (value) {
case "reset":
this._deleteCloudData();
break;
case "download":
this._downloadSupportPackage();
break;
}
}
private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_confirm_text"
),
confirmText: this.hass.localize("ui.panel.config.cloud.account.reset"),
destructive: true,
});
if (!confirm) {
return;
}
try {
await removeCloudData(this.hass);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.reset_data_failed"
),
text: err?.message,
});
return;
} finally {
fireEvent(this, "ha-refresh-cloud-status");
}
}
private async _downloadSupportPackage() {
showSupportPackageDialog(this);
}
static get styles() {
return [
haStyle,
cloudSubpageStyle,
cloudSignedOutStyle,
css`
.content {
padding-bottom: 24px;
gap: var(--ha-space-4);
}
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
ha-card {
overflow: hidden;
}
ha-card .card-header {
margin-bottom: -8px;
}
h1 {
margin: 0;
ha-alert,
cloud-login {
display: block;
width: 100%;
max-width: 600px;
margin-inline: auto;
}
`,
];
+10 -32
View File
@@ -9,14 +9,12 @@ import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import { setAssistPipelinePreferred } from "../../../../data/assist_pipeline";
import { cloudLogin } from "../../../../data/cloud";
import { loginHaCloud } from "../../../../data/onboarding";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../lovelace/custom-card-helpers";
import { showCloudAlreadyConnectedDialog } from "../dialog-cloud-already-connected/show-dialog-cloud-already-connected";
@@ -37,6 +35,8 @@ export class CloudLogin extends LitElement {
@property({ type: Boolean, attribute: "card-less" }) public cardLess = false;
@property() public lead?: string;
@query("#email", true) public emailField!: HaInput;
@query("#password", true) private _passwordField!: HaInput;
@@ -50,21 +50,13 @@ export class CloudLogin extends LitElement {
return this._renderLoginForm();
}
return html`
<ha-card
outlined
.header=${this.localize(
`ui.panel.${this.translationKeyPanel}.login.sign_in`
)}
>
${this._renderLoginForm()}
</ha-card>
`;
return html`<ha-card outlined>${this._renderLoginForm()}</ha-card>`;
}
private _renderLoginForm() {
return html`
<div class="card-content login-form">
${this.lead ? html`<p class="lead">${this.lead}</p>` : nothing}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -219,28 +211,12 @@ export class CloudLogin extends LitElement {
try {
if (this.hass) {
const result = await cloudLogin({
await cloudLogin({
hass: this.hass,
email,
...(code ? { code } : { password }),
check_connection: checkConnection,
});
if (result.cloud_pipeline) {
if (
await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.login.cloud_pipeline_title"
),
text: this.hass.localize(
"ui.panel.config.cloud.login.cloud_pipeline_text"
),
confirmText: this.hass.localize("ui.common.yes"),
dismissText: this.hass.localize("ui.common.no"),
})
) {
setAssistPipelinePreferred(this.hass, result.cloud_pipeline);
}
}
} else {
// for onboarding
await loginHaCloud({
@@ -317,9 +293,6 @@ export class CloudLogin extends LitElement {
ha-card {
overflow: hidden;
}
ha-card .card-header {
margin-bottom: -8px;
}
.card-actions {
display: flex;
justify-content: space-between;
@@ -329,6 +302,11 @@ export class CloudLogin extends LitElement {
display: flex;
flex-direction: column;
}
.lead {
margin: 0 0 var(--ha-space-2);
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
`,
];
}
+540 -220
View File
@@ -1,212 +1,377 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { mdiEmailCheckOutline } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { navigate } from "../../../../common/navigate";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-spinner";
import "../../../../components/ha-svg-icon";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import { cloudRegister, cloudResendVerification } from "../../../../data/cloud";
import type { CloudAutoLogin, CloudStatus } from "../../../../data/cloud";
import {
attemptCloudAutoLoginNow,
cloudRegisterAutoLogin,
resendCloudAutoLoginConfirm,
subscribeCloudEvents,
} from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
import { showToast } from "../../../../util/toast";
import "../cloud-signed-out-menu";
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
const BACK_PATH = "/config/cloud/start";
@customElement("cloud-register")
export class CloudRegister extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property() public email?: string;
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@state() private _view: "form" | "confirm" = "form";
@state() private _requestInProgress = false;
@state() private _password = "";
@state() private _resendInProgress = false;
@state() private _autoLoginError?: string;
@state() private _error?: string;
@query("#email", true) private _emailField!: HaInput;
@state() private _success?: string;
@query("#password", true) private _passwordField!: HaInput;
@state() private _registeredEmail = "";
private _unsubCloudEvents?: Promise<UnsubscribeFunc>;
@query("#email") private _emailField?: HaInput;
@query("#password") private _passwordField?: HaInput;
@query("#confirm-title") private _confirmTitle?: HTMLElement;
private get _autoLogin(): CloudAutoLogin | null {
const status = this.cloudStatus;
if (!status || status.logged_in) {
return null;
}
return status.auto_login ?? null;
}
private get _pendingEmail(): string {
return this._registeredEmail || this._autoLogin?.email || "";
}
public connectedCallback(): void {
super.connectedCallback();
if (this._autoLogin) {
this._view = "confirm";
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._stopWatchingAutoLogin();
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (this._view !== "confirm") {
return;
}
const failedKey = this._autoLogin?.failed;
if (failedKey && !this._autoLoginError) {
this._failAutoLogin(failedKey);
return;
}
this._watchAutoLogin();
}
protected render(): TemplateResult {
const confirming = this._view === "confirm";
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
back-path=${BACK_PATH}
.backCallback=${confirming ? this._handleConfirmBack : undefined}
.header=${this.hass.localize("ui.panel.config.cloud.register.headline")}
>
<cloud-signed-out-menu
slot="toolbar-icon"
.hass=${this.hass}
></cloud-signed-out-menu>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
<span slot="header"
>${this.hass.localize(
"ui.panel.config.cloud.register.headline"
)}</span
>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information2"
)}
</p>
<ul>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_remote_control"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_google_home"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_amazon_alexa"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_webhook_apps"
)}
</li>
</ul>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information3"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>Nabu&nbsp;Casa,&nbsp;Inc</a
>
${this.hass.localize(
"ui.panel.config.cloud.register.information3a"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information4"
)}
</p>
<ul>
<li>
<a
href="https://www.nabucasa.com/tos/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.register.link_terms_conditions"
)}
</a>
</li>
<li>
<a
href="https://www.nabucasa.com/privacy_policy/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.register.link_privacy_policy"
)}
</a>
</li>
</ul>
</div>
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.cloud.register.create_account"
)}
><div class="card-content register-form">
${
this._error
? html`<ha-alert alert-type="error"
>${this._error}</ha-alert
>`
: ""
}
<ha-input
autofocus
id="email"
name="email"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.email_address"
)}
type="email"
autocomplete="email"
required
.value=${this.email ?? ""}
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.email_error_msg"
)}
></ha-input>
<ha-input
id="password"
type="password"
password-toggle
name="password"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.password"
)}
.value=${this._password}
autocomplete="new-password"
minlength="8"
required
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg"
)}
></ha-input>
</div>
<div class="card-actions">
<button
class="link"
.disabled=${this._requestInProgress}
@click=${this._handleResendVerifyEmail}
>
${this.hass.localize(
"ui.panel.config.cloud.register.resend_confirm_email"
)}
</button>
<ha-progress-button
@click=${this._handleRegister}
.progress=${this._requestInProgress}
>${this.hass.localize(
"ui.panel.config.cloud.register.start_trial"
)}</ha-progress-button
>
</div>
</ha-card>
</ha-config-section>
${confirming ? this._renderConfirm() : this._renderForm()}
</div>
</hass-subpage>
`;
}
private _renderForm(): TemplateResult {
return html`
<ha-card outlined>
<div class="card-header-block">
<h2>
${this.hass.localize(
"ui.panel.config.cloud.register.create_account"
)}
</h2>
<p>
${this.hass.localize("ui.panel.config.cloud.register.information")}
</p>
</div>
<div class="card-content register-form">
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<ha-input
autofocus
id="email"
name="email"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.email_address"
)}
type="email"
autocomplete="email"
required
.value=${this.email ?? ""}
.disabled=${this._requestInProgress}
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.email_error_msg"
)}
></ha-input>
<ha-input
id="password"
type="password"
password-toggle
name="password"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.password"
)}
autocomplete="new-password"
minlength="8"
required
.disabled=${this._requestInProgress}
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg"
)}
></ha-input>
<p class="terms">
${this.hass.localize(
"ui.panel.config.cloud.register.terms_notice",
{
terms: html`<a
href="https://www.nabucasa.com/tos/"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.cloud.register.link_terms_conditions"
)}</a
>`,
privacy_policy: html`<a
href="https://www.nabucasa.com/privacy_policy/"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.cloud.register.link_privacy_policy"
)}</a
>`,
}
)}
</p>
</div>
<div class="card-actions split">
<ha-button
appearance="plain"
.disabled=${this._requestInProgress}
@click=${this._handleSignInInstead}
>
${this.hass.localize(
"ui.panel.config.cloud.register.sign_in_instead"
)}
</ha-button>
<ha-progress-button
@click=${this._handleRegister}
.progress=${this._requestInProgress}
>${this.hass.localize(
"ui.panel.config.cloud.register.start_trial"
)}</ha-progress-button
>
</div>
</ha-card>
`;
}
private _renderConfirm(): TemplateResult {
return html`
<ha-card outlined>
<div class="card-content confirm">
${
this._autoLoginError
? html`<ha-alert alert-type="error">
${this._autoLoginError}
</ha-alert>`
: nothing
}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${
this._success
? html`<ha-alert alert-type="success">${this._success}</ha-alert>`
: nothing
}
<div class="confirm-icon">
<ha-svg-icon .path=${mdiEmailCheckOutline}></ha-svg-icon>
</div>
<h2 id="confirm-title" tabindex="-1">
${this.hass.localize(
"ui.panel.config.cloud.register.check_your_email"
)}
</h2>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.confirm_email_body",
{
email: html`<span class="email">${this._pendingEmail}</span>`,
}
)}
</p>
${
this._autoLoginError
? nothing
: html`
<div class="waiting">
<ha-spinner size="tiny"></ha-spinner>
<span>
${this.hass.localize(
"ui.panel.config.cloud.register.waiting_for_confirmation"
)}
</span>
</div>
`
}
</div>
<div class="card-actions split confirm-actions">
<ha-button appearance="plain" @click=${this._handleCancelPending}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
${
this._autoLoginError
? html`<ha-button
appearance="accent"
@click=${this._handleSignInInstead}
>
${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
</ha-button>`
: html`<ha-button
appearance="accent"
@click=${this._handleAttemptNow}
>
${this.hass.localize(
"ui.panel.config.cloud.register.clicked_confirm"
)}
</ha-button>`
}
</div>
</ha-card>
${
this._autoLoginError
? nothing
: html`
<p class="footnote">
${this.hass.localize(
"ui.panel.config.cloud.register.nothing_arrived",
{
resend_link: html`<button
class="link"
.disabled=${this._resendInProgress}
@click=${this._handleResendVerifyEmail}
>
${this.hass.localize(
"ui.panel.config.cloud.register.resend_link"
)}
</button>`,
}
)}
</p>
`
}
`;
}
private _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
this._handleRegister();
}
}
private _handleSignInInstead() {
const confirming = this._view === "confirm";
fireEvent(this, "cloud-email-changed", {
// The field wins on the form: it holds whatever was typed after a cancel.
value: this._emailField?.value || this._pendingEmail || this.email || "",
});
this._stopWatchingAutoLogin();
if (confirming) {
fireEvent(this, "cloud-cancel-auto-login");
}
// Replaces only from the waiting view, whose registration is being
// cancelled: browser-back must not return to it. The form is a page worth
// going back to.
navigate("/config/cloud/login", { replace: confirming });
}
private _handleConfirmBack = () => {
this._stopWatchingAutoLogin();
fireEvent(this, "cloud-cancel-auto-login");
navigate(BACK_PATH, { replace: true });
};
private _handleCancelPending() {
this._resetToForm();
fireEvent(this, "cloud-cancel-auto-login");
}
private _resetToForm() {
this._stopWatchingAutoLogin();
this._view = "form";
this._autoLoginError = undefined;
this._registeredEmail = "";
this._error = undefined;
this._success = undefined;
}
private async _handleRegister() {
const emailField = this._emailField;
const passwordField = this._passwordField;
if (!emailField || !passwordField) {
return;
}
if (!emailField.reportValidity()) {
passwordField.reportValidity();
emailField.focus();
@@ -222,92 +387,246 @@ export class CloudRegister extends LitElement {
const password = passwordField.value || "";
this._requestInProgress = true;
this._error = undefined;
try {
await cloudRegister(this.hass, email, password);
this._verificationEmailSent(email, "account_created");
await cloudRegisterAutoLogin(this.hass, email, password);
} catch (err: any) {
this._password = "";
this._requestInProgress = false;
passwordField.value = "";
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
private async _handleResendVerifyEmail() {
const emailField = this._emailField;
if (!emailField.reportValidity()) {
emailField.focus();
err?.body?.message || this.hass.localize("ui.common.unknown_error");
return;
}
const email = emailField.value || "";
this._requestInProgress = true;
const doResend = async (username: string) => {
try {
await cloudResendVerification(this.hass, username);
this._verificationEmailSent(username, "verification_email_sent");
} catch (err: any) {
const errCode = err && err.body && err.body.code;
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
await doResend(username.toLowerCase());
} else {
this._requestInProgress = false;
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
};
await doResend(email);
this._registeredEmail = email;
this._requestInProgress = false;
this._view = "confirm";
this._autoLoginError = undefined;
fireEvent(this, "cloud-email-changed", { value: email });
fireEvent(this, "cloud-auto-login-started");
fireEvent(this, "ha-refresh-cloud-status");
this._watchAutoLogin();
await this.updateComplete;
this._confirmTitle?.focus();
}
private _verificationEmailSent(
email: string,
messageKey: "account_created" | "verification_email_sent"
) {
this._requestInProgress = false;
this._password = "";
fireEvent(this, "cloud-email-changed", { value: email });
fireEvent(this, "cloud-done", {
flashMessage: this.hass.localize(
`ui.panel.config.cloud.register.${messageKey}`
),
private _watchAutoLogin() {
if (this._unsubCloudEvents || this._autoLoginError) {
return;
}
const subscription = subscribeCloudEvents(this.hass, (event) => {
if (event.type === "login") {
fireEvent(this, "ha-refresh-cloud-status");
} else if (event.type === "auto_login_failed") {
this._failAutoLogin(event.translation_key);
} else if (event.type === "auto_login_cancelled") {
this._resetToForm();
fireEvent(this, "ha-refresh-cloud-status");
}
});
this._unsubCloudEvents = subscription;
subscription.catch(() => {
if (this._unsubCloudEvents === subscription) {
this._unsubCloudEvents = undefined;
}
});
}
private async _localizeBackendMessage(
key: string,
domain = "cloud"
): Promise<string | undefined> {
const localize = await this.hass.loadBackendTranslation(
"exceptions",
domain
);
return (
localize(`component.${domain}.exceptions.${key}.message`) || undefined
);
}
private async _websocketErrorMessage(err: any): Promise<string> {
if (err?.translation_key) {
const message = await this._localizeBackendMessage(
err.translation_key,
err.translation_domain
);
if (message) {
return message;
}
}
return err?.message || this.hass.localize("ui.common.unknown_error");
}
private async _failAutoLogin(translationKey?: string | null) {
this._stopWatchingAutoLogin();
const reason = translationKey
? await this._localizeBackendMessage(translationKey)
: undefined;
this._error = undefined;
this._success = undefined;
this._autoLoginError =
reason ||
this.hass.localize("ui.panel.config.cloud.register.auto_login_failed");
fireEvent(this, "ha-refresh-cloud-status");
}
private _stopWatchingAutoLogin() {
this._unsubCloudEvents?.then((unsub) => unsub()).catch(() => undefined);
this._unsubCloudEvents = undefined;
}
private async _handleAttemptNow() {
this._watchAutoLogin();
try {
await attemptCloudAutoLoginNow(this.hass);
} catch (err: any) {
showToast(this, { message: await this._websocketErrorMessage(err) });
return;
}
showToast(this, {
message: {
translationKey: "ui.panel.config.cloud.register.checking_confirmation",
},
});
}
private async _handleResendVerifyEmail() {
if (this._resendInProgress) {
return;
}
this._error = undefined;
this._success = undefined;
this._resendInProgress = true;
try {
await resendCloudAutoLoginConfirm(this.hass);
this._success = this.hass.localize(
"ui.panel.config.cloud.register.verification_email_sent"
);
} catch (err: any) {
this._error = await this._websocketErrorMessage(err);
} finally {
this._resendInProgress = false;
}
}
static get styles() {
return [
haStyle,
cloudSubpageStyle,
cloudSignedOutStyle,
css`
[slot="introduction"] {
margin: -1em 0;
.content {
gap: var(--ha-space-3);
}
[slot="introduction"] a {
color: var(--primary-color);
.card-header-block {
padding: var(--ha-space-4) var(--ha-space-4) 0;
}
a {
color: var(--primary-color);
}
h1 {
margin: 0;
.card-header-block p {
margin: var(--ha-space-2) 0 0;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
.register-form {
display: flex;
flex-direction: column;
}
.card-actions {
.terms {
margin: var(--ha-space-2) 0 0;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
.terms a,
.footnote a,
.footnote button.link {
color: var(--primary-color);
}
.card-actions.split {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-actions.confirm-actions {
border-top: none;
}
.confirm {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--ha-space-3);
padding: var(--ha-space-8) var(--ha-space-4) var(--ha-space-5);
}
.confirm ha-alert {
width: 100%;
text-align: initial;
}
.confirm-icon {
width: 56px;
height: 56px;
border-radius: var(--ha-border-radius-pill);
background: color-mix(in srgb, var(--info-color) 18%, transparent);
color: var(--info-color);
display: flex;
align-items: center;
justify-content: center;
--mdc-icon-size: 28px;
}
/* Focused programmatically to announce the view; no visible ring. */
#confirm-title:focus {
outline: none;
}
.confirm p {
margin: 0;
max-width: 420px;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
.confirm .email {
display: inline-block;
max-width: 100%;
overflow-wrap: anywhere;
}
.waiting {
display: flex;
align-items: center;
gap: var(--ha-space-2);
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.footnote {
display: block;
width: 100%;
max-width: 600px;
margin: 0 auto;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
text-align: center;
text-wrap: pretty;
}
/* Not column-reverse: that paints the buttons against DOM order, so
keyboard and screen-reader users reach them back to front. */
@media (max-width: 500px) {
.card-actions.split {
flex-direction: column;
align-items: stretch;
gap: var(--ha-space-2);
}
}
`,
];
}
@@ -319,6 +638,7 @@ declare global {
}
interface HASSDomEvents {
"cloud-done": { flashMessage: string };
"cloud-cancel-auto-login": undefined;
"cloud-auto-login-started": undefined;
}
}
@@ -0,0 +1,300 @@
import {
mdiBackupRestore,
mdiCellphone,
mdiEarth,
mdiHandHeart,
mdiMicrophone,
mdiMicrophoneMessage,
} from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { navigate } from "../../../../common/navigate";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../cloud-signed-out-menu";
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
const FEATURES = [
{ icon: mdiHandHeart, color: "--red-color", key: "feature_support" },
{ icon: mdiEarth, color: "--blue-color", key: "feature_remote" },
{ icon: mdiBackupRestore, color: "--green-color", key: "feature_backup" },
{
icon: mdiMicrophoneMessage,
color: "--cyan-color",
key: "feature_voice_control",
},
{
icon: mdiMicrophone,
color: "--purple-color",
key: "feature_voice_quality",
},
{ icon: mdiCellphone, color: "--primary-color", key: "feature_companion" },
] as const;
@customElement("cloud-start")
export class CloudStart extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize(
"ui.panel.config.cloud.login.landing_title"
)}
>
<cloud-signed-out-menu
slot="toolbar-icon"
.hass=${this.hass}
></cloud-signed-out-menu>
<div class="content">
<div class="landing">
<div class="pitch">
<h2>
${this.hass.localize("ui.panel.config.cloud.login.hero_title")}
</h2>
<p class="lead">
${this.hass.localize("ui.panel.config.cloud.login.hero_lead")}
</p>
</div>
<ha-card outlined class="features">
<div class="card-content feature-list">
${FEATURES.map(
(feature) => html`
<div class="feature">
<div
class="icon-tile"
style=${styleMap({
"--feature-tint": `var(${feature.color})`,
})}
>
<ha-svg-icon .path=${feature.icon}></ha-svg-icon>
</div>
<div class="feature-text">
<div class="feature-title">
${this.hass.localize(
`ui.panel.config.cloud.login.${feature.key}_title`
)}
</div>
<p>
${this.hass.localize(
`ui.panel.config.cloud.login.${feature.key}_body`
)}
</p>
</div>
</div>
`
)}
</div>
</ha-card>
<div class="actions">
<div class="action-buttons">
<ha-button
size="l"
appearance="accent"
@click=${this._handleRegister}
>
${this.hass.localize(
"ui.panel.config.cloud.register.headline"
)}
</ha-button>
<ha-button appearance="plain" @click=${this._handleSignIn}>
${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
</ha-button>
</div>
<p class="trial-note">
${this.hass.localize("ui.panel.config.cloud.login.trial_note")}
</p>
</div>
</div>
<p class="footnote">
${this.hass.localize("ui.panel.config.cloud.login.partner_note", {
nabu_casa_link: html`<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>Nabu&nbsp;Casa,&nbsp;Inc</a
>`,
})}
</p>
</div>
</hass-subpage>
`;
}
private _handleSignIn() {
navigate("/config/cloud/login");
}
private _handleRegister() {
navigate("/config/cloud/register");
}
static get styles() {
return [
haStyle,
cloudSubpageStyle,
cloudSignedOutStyle,
css`
.content {
min-height: 100%;
gap: var(--ha-space-4);
container-type: inline-size;
}
.landing {
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
width: 100%;
max-width: 600px;
margin-inline: auto;
}
.pitch .lead {
margin: var(--ha-space-2) 0 0;
line-height: var(--ha-line-height-normal);
color: var(--secondary-text-color);
text-wrap: pretty;
}
.feature-list {
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
padding: var(--ha-space-5) var(--ha-space-4);
}
.feature {
display: flex;
gap: var(--ha-space-3);
align-items: flex-start;
}
.feature-title {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
.feature-text p {
margin: var(--ha-space-1) 0 0;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
text-wrap: pretty;
}
.icon-tile {
width: 40px;
height: 40px;
border-radius: var(--ha-border-radius-pill);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: color-mix(in srgb, var(--feature-tint) 15%, transparent);
color: var(--feature-tint);
}
/* Keeps the primary action reachable while the feature list scrolls. */
.actions {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
padding: var(--ha-space-3) 0
calc(var(--safe-area-inset-bottom) + var(--ha-space-3));
background: var(--primary-background-color);
box-shadow: 0 -1px 0 var(--divider-color);
}
.action-buttons {
display: flex;
gap: var(--ha-space-2);
}
.action-buttons ha-button {
flex: 1;
/* Matches the primary action's size="l" height without also giving
the secondary its larger type. */
--ha-button-height: 48px;
}
.trial-note {
margin: 0;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
text-align: center;
}
.footnote {
width: 100%;
max-width: 960px;
/* Keep the footer at the bottom on short pages. */
margin: auto auto 0;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
text-align: center;
text-wrap: pretty;
}
.footnote a {
color: var(--primary-color);
}
@container (min-width: 700px) {
.landing {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
grid-template-rows: auto auto 1fr;
column-gap: var(--ha-space-7);
row-gap: var(--ha-space-6);
align-items: start;
max-width: 960px;
}
.pitch {
grid-column: 1;
grid-row: 1;
}
.actions {
grid-column: 1;
grid-row: 2;
position: static;
padding: 0;
background: none;
box-shadow: none;
}
.features {
grid-column: 2;
grid-row: 1 / 4;
}
.action-buttons {
flex-direction: column;
max-width: 340px;
}
.action-buttons ha-button {
flex: initial;
width: 100%;
}
.trial-note {
text-align: start;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-start": CloudStart;
}
}
+41 -3
View File
@@ -1,9 +1,10 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { CloudStatus } from "../../data/cloud";
import { fetchCloudStatus } from "../../data/cloud";
import { fetchCloudStatus, subscribeCloudEvents } from "../../data/cloud";
import {
entityRegistryByEntityId,
entityRegistryById,
@@ -256,6 +257,10 @@ class HaPanelConfig extends HassRouterPage {
private _listeners: (() => void)[] = [];
private _unsubCloudEvents?: Promise<UnsubscribeFunc>;
private _cloudStatusRequestId = 0;
public connectedCallback() {
super.connectedCallback();
this._listeners.push(
@@ -268,6 +273,10 @@ class HaPanelConfig extends HassRouterPage {
this._wideSidebar = matches;
})
);
if (this.hass && isComponentLoaded(this.hass.config, "cloud")) {
this._subscribeCloudEvents();
this._updateCloudStatus();
}
}
public disconnectedCallback() {
@@ -275,19 +284,42 @@ class HaPanelConfig extends HassRouterPage {
while (this._listeners.length) {
this._listeners.pop()!();
}
this._unsubCloudEvents?.then((unsub) => unsub()).catch(() => undefined);
this._unsubCloudEvents = undefined;
entityRegistryByEntityId.clear();
entityRegistryById.clear();
}
private _subscribeCloudEvents() {
if (
this._unsubCloudEvents ||
!this.hass ||
!isComponentLoaded(this.hass.config, "cloud")
) {
return;
}
const subscription = subscribeCloudEvents(this.hass, () => {
this._updateCloudStatus();
});
this._unsubCloudEvents = subscription;
subscription.catch(() => {
if (this._unsubCloudEvents === subscription) {
this._unsubCloudEvents = undefined;
}
});
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
this.hass.loadBackendTranslation("services");
if (isComponentLoaded(this.hass.config, "cloud")) {
this._updateCloudStatus();
this.addEventListener("connection-status", (ev) => {
if (ev.detail === "connected") {
this._updateCloudStatus();
this._subscribeCloudEvents();
}
});
}
@@ -321,7 +353,13 @@ class HaPanelConfig extends HassRouterPage {
}
private async _updateCloudStatus() {
this._cloudStatus = await fetchCloudStatus(this.hass);
const requestId = ++this._cloudStatusRequestId;
const status = await fetchCloudStatus(this.hass);
if (requestId !== this._cloudStatusRequestId) {
return;
}
this._cloudStatus = status;
if (
// Relayer connecting
@@ -101,12 +101,16 @@ export const renderConfigEntryError = (
): TemplateResult => {
if (entry.reason) {
if (entry.error_reason_translation_key) {
// The error may be translated by another integration, e.g. one raised by
// a shared helper and owned by the homeassistant integration.
const translationDomain =
entry.error_reason_translation_domain || entry.domain;
const lokalisePromExc = hass
.loadBackendTranslation("exceptions", entry.domain)
.loadBackendTranslation("exceptions", translationDomain)
.then(
(localize) =>
localize(
`component.${entry.domain}.exceptions.${entry.error_reason_translation_key}.message`,
`component.${translationDomain}.exceptions.${entry.error_reason_translation_key}.message`,
entry.error_reason_translation_placeholders ?? undefined
) || entry.reason
);
@@ -267,6 +267,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
pref_disable_polling: false,
disabled_by: null,
reason: null,
error_reason_translation_domain: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
})
@@ -306,6 +306,7 @@ class DialogZWaveJSAddNode extends LitElement {
if (this._step === "choose_security_strategy") {
return html`<zwave-js-add-node-select-security-strategy
.hass=${this.hass}
.inclusionStrategy=${this._inclusionStrategy}
@z-wave-strategy-selected=${this._setSecurityStrategy}
></zwave-js-add-node-select-security-strategy>`;
}
@@ -1,4 +1,4 @@
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { css, html, LitElement } from "lit";
import memoizeOne from "memoize-one";
@@ -14,7 +14,7 @@ import "../../../../../../components/ha-form/ha-form";
export class ZWaveJsAddNodeSelectMethod extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() public _inclusionStrategy?: InclusionStrategy;
@property({ attribute: false }) public inclusionStrategy?: InclusionStrategy;
private _getSchema = memoizeOne((localize: LocalizeFunc): HaFormSchema[] => [
{
@@ -62,7 +62,7 @@ export class ZWaveJsAddNodeSelectMethod extends LitElement {
return html`
<ha-form
.schema=${this._getSchema(this.hass.localize)}
.data=${{ strategy: this._inclusionStrategy?.toString() }}
.data=${{ strategy: this.inclusionStrategy?.toString() }}
@value-changed=${this._selectStrategy}
.computeLabel=${this._computeLabel}
>
@@ -395,9 +395,11 @@ export class VoiceAssistantsExpose extends LitElement {
aliases: entry?.aliases || [],
};
}
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
result[entityId].assistants
);
if (result[entityId]) {
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
result[entityId].assistants
);
}
});
}
return Object.values(result);
+10 -2
View File
@@ -49,6 +49,7 @@ import {
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import { entityTypesNeedStates } from "../../data/entity/entity_type";
import "../../components/ha-spinner";
import "../../components/ha-top-app-bar-fixed";
import type { EntitySources } from "../../data/entity/entity_sources";
@@ -216,6 +217,7 @@ class HaPanelHistory extends LitElement {
.hass=${this.hass}
.value=${this._targetPickerValue}
.filters=${this._filters}
.entitySources=${this._entitySources}
.disabled=${this._isLoading}
.description=${this.hass.localize(
"ui.panel.history.no_targets"
@@ -536,8 +538,10 @@ class HaPanelHistory extends LitElement {
this.hass.areas
),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
// Only a device class narrows down using the states.
entityTypesNeedStates(this._filters.types)
? this.hass.states
: EMPTY_STATES,
this.hass.entities,
this._entitySources
);
@@ -732,6 +736,10 @@ class HaPanelHistory extends LitElement {
haStyle,
haStyleScrollbar,
css`
:host {
/* The target picker chips need more room than a plain filter list. */
--ha-filter-pane-width: 340px;
}
ha-top-app-bar-fixed {
height: 100vh;
overflow-x: hidden;
+7 -5
View File
@@ -16,7 +16,7 @@ import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import "../components/home-favorites-editor";
import "../../../components/entity/ha-favorites-editor";
import "../components/home-shortcuts-editor";
import type { EditHomeDialogParams } from "./show-dialog-edit-home";
@@ -123,14 +123,16 @@ export class DialogEditHome
@value-changed=${this._welcomeChanged}
></ha-form>
<home-favorites-editor
.hass=${this.hass}
<ha-favorites-editor
.favorites=${this._state.favorite_entities}
.label=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.favorite_entities"
)}
.addButtonLabel=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
)}
@value-changed=${this._favoriteEntitiesChanged}
></home-favorites-editor>
></ha-favorites-editor>
<ha-form
.hass=${this.hass}
@@ -309,7 +311,7 @@ export class DialogEditHome
display: block;
}
home-favorites-editor {
ha-favorites-editor {
display: block;
margin-top: var(--ha-space-2);
margin-bottom: var(--ha-space-4);
+8 -2
View File
@@ -44,6 +44,7 @@ import {
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import { entityTypesNeedStates } from "../../data/entity/entity_type";
import "../../components/ha-top-app-bar-fixed";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { EntitySources } from "../../data/entity/entity_sources";
@@ -163,6 +164,7 @@ export class HaPanelLogbook extends LitElement {
.hass=${this.hass}
.value=${this._targetPickerValue}
.filters=${this._filters}
.entitySources=${this._entitySources}
.entityFilter=${this._filterFunc}
.description=${this.hass.localize(
"ui.panel.logbook.no_targets"
@@ -331,8 +333,10 @@ export class HaPanelLogbook extends LitElement {
return this.__filterEntityIds(
targetEntities ?? this.__logbookEntityIds(this.hass.states),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
// Only a device class narrows down using the states.
entityTypesNeedStates(this._filters.types)
? this.hass.states
: EMPTY_STATES,
this.hass.entities,
this._entitySources
);
@@ -579,6 +583,8 @@ export class HaPanelLogbook extends LitElement {
:host {
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
--ha-generic-picker-max-width: 400px;
/* The target picker chips need more room than a plain filter list. */
--ha-filter-pane-width: 340px;
}
.content {
@@ -194,6 +194,7 @@ export class HuiEnergyDevicesGraphCard
this._legendData
)}
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
.sonificationLabelFormatter=${this._sonificationLabel}
.extraComponents=${[PieChart]}
.expandLegend=${this._config.expand_legend}
click-label-for-more-info
@@ -293,6 +294,14 @@ export class HuiEnergyDevicesGraphCard
}
);
// The chart data is keyed on statistic ids, which is what Chart2Music would
// otherwise announce. Names that aren't statistics — the untracked slice —
// are already display text, so those stay as they are.
private _sonificationLabel = (label: string): string | undefined =>
this._deviceLabels[label] || this._data?.statsMetadata[label]
? this._getDeviceName(label)
: undefined;
private _getDeviceName(statisticId: string): string {
const suffix = this._compoundStats.includes(statisticId)
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
+183
View File
@@ -0,0 +1,183 @@
import { consume, type ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import "../../../components/ha-card";
import "../../../components/ha-relative-time";
import "../../../components/ha-state-icon";
import "../../../components/tile/ha-tile-container";
import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-info";
import { formattersContext } from "../../../data/context";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import { tileCardStyle } from "./tile/tile-card-style";
import type { AlertCardConfig } from "./types";
@customElement("hui-alert-card")
export class HuiAlertCard extends LitElement implements LovelaceCard {
@state() private _config?: AlertCardConfig;
@state()
@consumeEntityState({ entityIdPath: ["_config", "entity"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
public setConfig(config: AlertCardConfig): void {
if (
!isValidEntityId(config.entity) ||
(config.color !== undefined && typeof config.color !== "string") ||
(config.pulse !== undefined && typeof config.pulse !== "boolean")
) {
throw new Error("Invalid configuration");
}
this._config = config;
}
public getCardSize(): number {
return 1;
}
public getGridOptions(): LovelaceGridOptions {
return {
columns: 6,
rows: 1,
min_columns: 6,
min_rows: 1,
};
}
protected render() {
if (!this._config || !this._formatters) {
return nothing;
}
const stateObj = this._stateObj;
if (!stateObj) {
return nothing;
}
const stateDisplay = this._formatters.formatEntityState(stateObj);
const areaName = this._formatters.formatEntityName(stateObj, {
type: "area",
});
const pulse = this._config.pulse === true;
const hasColor =
this._config.color !== undefined && this._config.color !== "none";
return html`
<ha-card
class=${classMap({ pulse, "no-color": !hasColor })}
style=${styleMap({
"--ha-alert-color":
this._config.color && hasColor
? computeCssColor(this._config.color)
: undefined,
"--ha-alert-static-opacity": pulse
? undefined
: "var(--ha-alert-pulse-opacity)",
})}
>
<ha-tile-container
.interactive=${true}
.actionHandlerOptions=${{ hasHold: false, hasDoubleClick: false }}
@action=${this._handleAction}
>
<ha-tile-icon slot="icon">
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
</ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary">${computeStateName(stateObj)}</span>
<span slot="secondary">
${stateDisplay} ·
<ha-relative-time
.datetime=${stateObj.last_changed}
.format=${"short"}
></ha-relative-time>
${areaName ? html` · ${areaName}` : nothing}
</span>
</ha-tile-info>
</ha-tile-container>
</ha-card>
`;
}
private _handleAction(ev: ActionHandlerEvent): void {
if (ev.detail.action === "tap" && this._config) {
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
}
}
static styles = [
tileCardStyle,
css`
@keyframes pulse-opacity {
from {
opacity: 0;
}
to {
opacity: var(--ha-pulse-opacity, 0.3);
}
}
:host {
display: block;
--ha-alert-pulse-duration: 1s;
--ha-alert-pulse-opacity: 0.3;
--ha-alert-static-opacity: 0;
}
ha-card {
position: relative;
overflow: hidden;
height: 100%;
}
ha-card:not(.no-color) {
--tile-color: var(--ha-alert-color);
}
ha-card.no-color {
--tile-color: var(--secondary-text-color);
}
ha-card::before {
position: absolute;
inset: 0;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
background-color: var(--ha-alert-color);
content: "";
opacity: var(--ha-alert-static-opacity);
pointer-events: none;
}
ha-card.pulse::before {
--ha-pulse-opacity: var(--ha-alert-pulse-opacity);
animation: pulse-opacity var(--ha-alert-pulse-duration) ease-in-out
infinite alternate;
}
ha-card:not(.pulse)::before {
animation: none;
}
ha-tile-container {
position: relative;
}
@media (prefers-reduced-motion: reduce) {
ha-card::before {
animation: none;
opacity: var(--ha-alert-pulse-opacity);
}
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-alert-card": HuiAlertCard;
}
}
+7
View File
@@ -718,6 +718,13 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
double_tap_action?: ActionConfig;
}
export interface AlertCardConfig extends LovelaceCardConfig {
type: "alert";
entity: string;
color?: string;
pulse?: boolean;
}
export interface ToggleGroupCardConfig extends LovelaceCardConfig {
title: string;
entities: string[];
@@ -80,6 +80,7 @@ const LAZY_LOAD_TYPES = {
shortcut: () => import("../cards/hui-shortcut-card"),
"discovered-devices": () => import("../cards/hui-discovered-devices-card"),
repairs: () => import("../cards/hui-repairs-card"),
alert: () => import("../cards/hui-alert-card"),
updates: () => import("../cards/hui-updates-card"),
gauge: () => import("../cards/hui-gauge-card"),
"history-graph": () => import("../cards/hui-history-graph-card"),
@@ -1,8 +1,15 @@
import type { LovelacePanelConfig } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
import { fetchConfig, saveConfig } from "../../../data/lovelace/config/types";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../data/lovelace/config/types";
import {
fetchConfig,
isStrategyDashboard,
saveConfig,
} from "../../../data/lovelace/config/types";
import { fetchDashboards } from "../../../data/lovelace/dashboard";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
@@ -37,7 +44,15 @@ export const addEntitiesToLovelaceView = async (
return;
}
let lovelaceConfig;
// A strategy dashboard has no views of its own until the user takes control.
const hasViewList = (
config: LovelaceRawConfig | undefined
): config is LovelaceConfig => !!config && !isStrategyDashboard(config);
const hasViews = (config: LovelaceRawConfig | undefined) =>
hasViewList(config) && !!config.views?.length;
let lovelaceConfig: LovelaceRawConfig | undefined;
let urlPath: string | null = null;
if (mainLovelaceMode === "storage") {
try {
@@ -47,25 +62,36 @@ export const addEntitiesToLovelaceView = async (
}
}
if (!lovelaceConfig && storageDashs.length) {
// find first dashoard not in generated mode
if (!hasViews(lovelaceConfig)) {
// Prefer a dashboard that has views to add the card to, but keep the first
// usable one as a fallback so the user still gets the dashboard picker.
for (const storageDash of storageDashs) {
try {
// eslint-disable-next-line no-await-in-loop
lovelaceConfig = await fetchConfig(
const dashConfig = await fetchConfig(
hass.connection,
storageDash.url_path,
false
);
urlPath = storageDash.url_path;
break;
if (!hasViewList(dashConfig)) {
continue;
}
if (!hasViewList(lovelaceConfig)) {
lovelaceConfig = dashConfig;
urlPath = storageDash.url_path;
}
if (hasViews(dashConfig)) {
lovelaceConfig = dashConfig;
urlPath = storageDash.url_path;
break;
}
} catch (_err: any) {
// dashboard is in generated mode
}
}
}
if (!lovelaceConfig) {
if (!hasViewList(lovelaceConfig)) {
if (dashboards.length > storageDashs.length) {
// all storage dashboards are generated, but we have YAML dashboards just show the YAML config
showSuggestCardDialog(element, {
@@ -94,7 +120,7 @@ export const addEntitiesToLovelaceView = async (
showSuggestCardDialog(element, {
cardConfig,
sectionConfig,
lovelaceConfig: lovelaceConfig!,
lovelaceConfig,
saveConfig: async (newConfig: LovelaceConfig): Promise<void> => {
try {
await saveConfig(hass!, null, newConfig);
@@ -26,7 +26,7 @@ import type {
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { SelectOption } from "../../../../data/selector";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../../../../data/sensor_numeric_device_classes";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../../../../data/sensor_entity_constants";
import type { HomeAssistant } from "../../../../types";
import type {
LovelaceCardFeatureConfig,
@@ -9,13 +9,14 @@ import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-list";
import "../../../../components/ha-radio-list-item";
import "../../../../components/ha-select";
import "../../../../components/ha-spinner";
import "../../../../components/ha-dialog";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { fetchConfig } from "../../../../data/lovelace/config/types";
import { isStrategyView } from "../../../../data/lovelace/config/view";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import { fetchDashboards } from "../../../../data/lovelace/dashboard";
import { getDefaultPanelUrlPath } from "../../../../data/panel";
import { LOVELACE_PANEL } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { SelectViewDialogParams } from "./show-select-view-dialog";
@@ -42,11 +43,15 @@ export class HuiDialogSelectView extends LitElement {
@state() private _selectedViewIdx = 0;
@state() private _loading = false;
@state() private _open = false;
public showDialog(params: SelectViewDialogParams): void {
this._config = params.lovelaceConfig;
this._urlPath = params.urlPath;
this._selectedViewIdx = 0;
this._loading = false;
this._params = params;
this._open = true;
if (this._params.allowDashboardChange) {
@@ -68,8 +73,6 @@ export class HuiDialogSelectView extends LitElement {
return nothing;
}
const defaultPanel = getDefaultPanelUrlPath(this.hass);
return html`
<ha-dialog
.open=${this._open}
@@ -86,19 +89,19 @@ export class HuiDialogSelectView extends LitElement {
"ui.panel.lovelace.editor.select_view.dashboard_label"
)}
.disabled=${!this._dashboards.length}
.value=${this._urlPath || defaultPanel}
.value=${this._urlPath ?? LOVELACE_PANEL}
@selected=${this._dashboardChanged}
autofocus
.options=${this._dashboards
.map((dashboard) => ({
value: dashboard.url_path,
label: `${dashboard.title}${dashboard.id === "lovelace" ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
label: `${dashboard.title}${dashboard.id === LOVELACE_PANEL ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
disabled: dashboard.mode !== "storage",
}))
.sort((a, b) =>
a.value === "lovelace"
a.value === LOVELACE_PANEL
? -1
: b.value === "lovelace"
: b.value === LOVELACE_PANEL
? 1
: a.label.localeCompare(b.label)
)}
@@ -106,54 +109,7 @@ export class HuiDialogSelectView extends LitElement {
</ha-select>`
: nothing
}
${
!this._config || (this._config.views || []).length < 1
? html`<ha-alert alert-type="error"
>${this.hass.localize(
this._config
? "ui.panel.lovelace.editor.select_view.no_views"
: "ui.panel.lovelace.editor.select_view.no_config"
)}</ha-alert
>`
: this._config.views.length > 1
? html`
<ha-list>
${this._config.views.map((view, idx) => {
const isStrategy = isStrategyView(view);
return html`
<ha-radio-list-item
.graphic=${
this._config?.views.some(({ icon }) => icon)
? "icon"
: nothing
}
@click=${this._viewChanged}
.value=${idx.toString()}
.selected=${this._selectedViewIdx === idx}
.disabled=${
isStrategy && !this._params?.includeStrategyViews
}
?autofocus=${
idx === 0 && !this._params!.allowDashboardChange
}
>
<span>
${view.title}${
isStrategy
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
: nothing
}
</span>
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
</ha-radio-list-item>
`;
})}
</ha-list>
`
: nothing
}
${this._renderViews()}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@@ -164,7 +120,7 @@ export class HuiDialogSelectView extends LitElement {
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!this._config || (this._config.views || []).length < 1}
.disabled=${!this._selectableConfig}
@click=${this._selectView}
>
${this._params.actionLabel || this.hass!.localize("ui.common.move")}
@@ -174,30 +130,97 @@ export class HuiDialogSelectView extends LitElement {
`;
}
// While a config is loading the views on screen still belong to the
// previously selected dashboard, so nothing may be picked from them.
private get _selectableConfig(): LovelaceConfig | undefined {
return !this._loading && this._config?.views?.length
? this._config
: undefined;
}
private _renderViews() {
if (this._loading) {
return html`<div class="loading">
<ha-spinner size="medium"></ha-spinner>
</div>`;
}
if (!this._selectableConfig) {
return html`<ha-alert alert-type="error">
${this.hass.localize(
this._config
? "ui.panel.lovelace.editor.select_view.no_views"
: "ui.panel.lovelace.editor.select_view.no_config"
)}
</ha-alert>`;
}
const views = this._selectableConfig.views;
if (views.length < 2) {
return nothing;
}
const hasIcon = views.some(({ icon }) => icon);
return html`
<ha-list>
${views.map((view, idx) => {
const isStrategy = isStrategyView(view);
return html`
<ha-radio-list-item
.graphic=${hasIcon ? "icon" : nothing}
@click=${this._viewChanged}
.value=${idx.toString()}
.selected=${this._selectedViewIdx === idx}
.disabled=${isStrategy && !this._params?.includeStrategyViews}
?autofocus=${idx === 0 && !this._params!.allowDashboardChange}
>
<span>
${view.title}${
isStrategy
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
: nothing
}
</span>
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
</ha-radio-list-item>
`;
})}
</ha-list>
`;
}
private async _getDashboards() {
this._dashboards =
this._params!.dashboards || (await fetchDashboards(this.hass));
}
private async _dashboardChanged(ev: ValueChangedEvent<string>) {
let urlPath: string | null = ev.detail.value;
const urlPath = ev.detail.value === LOVELACE_PANEL ? null : ev.detail.value;
if (urlPath === this._urlPath) {
return;
}
if (urlPath === "lovelace") {
urlPath = null;
}
this._urlPath = urlPath;
this._selectedViewIdx = 0;
this._loading = true;
let config: LovelaceConfig | undefined;
try {
this._config = (await fetchConfig(
config = (await fetchConfig(
this.hass.connection,
urlPath,
false
)) as LovelaceConfig;
} catch (_err: any) {
this._config = undefined;
config = undefined;
}
// Responses can resolve out of order.
if (urlPath !== this._urlPath) {
return;
}
this._config = config;
this._selectedViewIdx = 0;
this._loading = false;
}
private _viewChanged(e) {
@@ -209,10 +232,14 @@ export class HuiDialogSelectView extends LitElement {
}
private _selectView(): void {
const config = this._selectableConfig;
if (!config) {
return;
}
fireEvent(this, "view-selected", { view: this._selectedViewIdx });
this._params!.viewSelectedCallback(
this._urlPath!,
this._config!,
config,
this._selectedViewIdx
);
this.closeDialog();
@@ -225,6 +252,10 @@ export class HuiDialogSelectView extends LitElement {
ha-select {
width: 100%;
}
.loading {
display: flex;
justify-content: center;
}
mwc-radio-list-item {
direction: ltr;
}
@@ -17,7 +17,7 @@ import type { HomeAssistant } from "../../../../types";
import {
isMediaSourceContentId,
resolveMediaSource,
resolveMediaSourceWithCache,
} from "../../../../data/media_source";
export interface BackgroundConfigTarget {
@@ -142,21 +142,35 @@ export class HuiViewBackgroundEditor extends LitElement {
`${(background.opacity ?? 100) / 100}`
);
const backgroundImage =
typeof background.image === "object"
? background.image.media_content_id
: background.image;
const backgroundImage = this._currentBackgroundImage();
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this._resolvedImage = result.url;
});
resolveMediaSourceWithCache(this.hass, backgroundImage).then(
(result) => {
// Discard if the image changed while resolving
if (this._currentBackgroundImage() === backgroundImage) {
this._resolvedImage = result.url;
}
},
() => {
if (this._currentBackgroundImage() === backgroundImage) {
this._resolvedImage = undefined;
}
}
);
} else {
this._resolvedImage = backgroundImage;
}
}
}
private _currentBackgroundImage(): string | undefined {
const background = this._backgroundData(this.config);
return typeof background.image === "object"
? background.image.media_content_id
: background.image;
}
protected render() {
if (!this.hass) {
return nothing;
@@ -21,6 +21,7 @@ import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { ShortcutItem } from "../../../../data/home_shortcuts";
import { resolveShortcutItems } from "../../../../data/home_shortcuts";
import type { HomeAssistant } from "../../../../types";
import { hasClimateEntities } from "../../../climate/strategies/climate-view-strategy";
import type {
AreaCardConfig,
DiscoveredDevicesCardConfig,
@@ -291,10 +292,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
generateEntityFilter(hass, filter)
);
const climateFilters = HOME_SUMMARIES_FILTERS.climate.map((filter) =>
generateEntityFilter(hass, filter)
);
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
generateEntityFilter(hass, filter)
);
@@ -307,9 +304,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
hass.panels.light && findEntities(allEntities, lightsFilters).length > 0;
const hasMediaPlayers =
findEntities(allEntities, mediaPlayerFilter).length > 0;
const hasClimate =
hass.panels.climate &&
findEntities(allEntities, climateFilters).length > 0;
const hasClimate = hass.panels.climate && hasClimateEntities(hass);
const hasSecurity =
hass.panels.security &&
findEntities(allEntities, securityFilters).length > 0;
@@ -5,7 +5,7 @@ import type { HomeAssistant } from "../../../types";
import type { LovelaceViewBackgroundConfig } from "../../../data/lovelace/config/view";
import {
isMediaSourceContentId,
resolveMediaSource,
resolveMediaSourceWithCache,
} from "../../../data/media_source";
@customElement("hui-view-background")
@@ -21,20 +21,37 @@ export class HUIViewBackground extends LitElement {
return nothing;
}
private _fetchMedia() {
const backgroundImage =
typeof this.background === "string"
? this.background
: typeof this.background?.image === "object"
? this.background.image.media_content_id
: this.background?.image;
private _getBackgroundImage(
background?: string | LovelaceViewBackgroundConfig
): string | undefined {
if (typeof background === "string") {
return background;
}
if (typeof background?.image === "object") {
return background.image.media_content_id;
}
return background?.image;
}
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this.resolvedImage = result.url;
});
} else {
private async _fetchMedia() {
const backgroundImage = this._getBackgroundImage(this.background);
if (!backgroundImage || !isMediaSourceContentId(backgroundImage)) {
this.resolvedImage = undefined;
return;
}
let resolvedUrl: string | undefined;
try {
resolvedUrl = (
await resolveMediaSourceWithCache(this.hass, backgroundImage)
).url;
} catch {
resolvedUrl = undefined;
}
// Discard if the background changed while resolving
if (this._getBackgroundImage(this.background) === backgroundImage) {
this.resolvedImage = resolvedUrl;
}
}
@@ -73,10 +90,7 @@ export class HUIViewBackground extends LitElement {
background?: string | LovelaceViewBackgroundConfig
) {
if (typeof background === "object" && background.image) {
const image =
typeof background.image === "object"
? background.image.media_content_id || ""
: background.image;
const image = this._getBackgroundImage(background) || "";
if (isMediaSourceContentId(image) && !this.resolvedImage) {
return null;
}
@@ -0,0 +1,283 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDelete, mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
type HASSDomEvent,
} from "../../../common/dom/fire_event";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import type {
SecurityAlertEntityConfig,
SecurityAlertSeverity,
} from "../../../data/frontend";
import {
internationalizationContext,
registriesContext,
statesContext,
} from "../../../data/context";
import "../../../components/entity/ha-entity-picker";
import "../../../components/entity/state-badge";
import "../../../components/ha-control-select-menu";
import "../../../components/ha-icon-button";
import "../../../components/ha-settings-row";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import { computeDefaultSecurityAlertSeverity } from "../strategies/security-alerts";
import {
createSecurityEntityFilter,
type SecurityEntityContext,
} from "../security-entity-filter";
import type { ValueChangedEvent } from "../../../types";
type SecurityEditorContext = SecurityEntityContext & {
language: ContextType<typeof internationalizationContext>["language"];
translationMetadata: ContextType<
typeof internationalizationContext
>["translationMetadata"];
};
@customElement("security-alerts-editor")
export class SecurityAlertsEditor extends LitElement {
@property({ attribute: false })
public alertEntities: SecurityAlertEntityConfig[] = [];
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _entityContext?: SecurityEditorContext;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
changedProps.has("_states") ||
changedProps.has("_registries") ||
changedProps.has("_i18n")
) {
this._entityContext = {
states: this._states,
...this._registries,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
};
}
}
protected render() {
return html`
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
<div class="alert-list">
${repeat(
this.alertEntities,
(alertEntity) => alertEntity.entity,
(alertEntity, index) => this._renderAlertEntity(alertEntity, index)
)}
</div>
</ha-sortable>
<ha-entity-picker
add-button
.addButtonLabel=${this._i18n.localize(
"ui.panel.security.editor.add_alert_entity"
)}
.excludeEntities=${this.alertEntities.map(({ entity }) => entity)}
.entityFilter=${this._entityFilter}
@value-changed=${this._add}
></ha-entity-picker>
`;
}
private _renderAlertEntity(
alertEntity: SecurityAlertEntityConfig,
index: number
) {
const stateObj = this._states[alertEntity.entity];
const { primary, secondary } =
stateObj && this._entityContext
? computeEntityPickerDisplay(this._entityContext, stateObj)
: { primary: alertEntity.entity, secondary: undefined };
const severity =
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
return html`
<div class="alert-row">
<div class="handle">
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
</div>
<ha-settings-row slim>
<state-badge slot="prefix" .stateObj=${stateObj}></state-badge>
<span slot="heading">${primary}</span>
${
secondary
? html`<span slot="description">${secondary}</span>`
: nothing
}
<ha-control-select-menu
show-arrow
hide-label
.label=${this._i18n.localize(
"ui.panel.security.editor.severity.label"
)}
.value=${severity}
.options=${(["alert", "warning"] as const).map((option) => ({
value: option,
label: this._i18n.localize(
`ui.panel.security.editor.severity.${option}`
),
}))}
data-index=${index}
@wa-select=${this._severityChanged}
></ha-control-select-menu>
<ha-icon-button
.path=${mdiDelete}
.label=${this._i18n.localize("ui.common.delete")}
data-index=${index}
@click=${this._removeClicked}
></ha-icon-button>
</ha-settings-row>
</div>
`;
}
private _changed(next: SecurityAlertEntityConfig[]): void {
fireEvent(this, "value-changed", { value: next });
}
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
private _getIndex(
ev: HASSDomCurrentTargetEvent<HTMLElement>
): number | undefined {
const index = Number(ev.currentTarget.dataset.index);
return Number.isInteger(index) ? index : undefined;
}
private _removeClicked(ev: HASSDomCurrentTargetEvent<HTMLElement>): void {
ev.stopPropagation();
const index = this._getIndex(ev);
if (index !== undefined) {
const next = [...this.alertEntities];
next.splice(index, 1);
this._changed(next);
}
}
private _severityChanged(
ev: HaDropdownSelectEvent<SecurityAlertSeverity> &
HASSDomCurrentTargetEvent<HTMLElement>
): void {
ev.stopPropagation();
const index = this._getIndex(ev);
if (index === undefined) {
return;
}
const next = [...this.alertEntities];
next[index] = { ...next[index], severity: ev.detail.item.value };
this._changed(next);
}
private _add(
ev: ValueChangedEvent<string | undefined> &
HASSDomCurrentTargetEvent<HaEntityPicker>
): void {
ev.stopPropagation();
const entity = ev.detail.value;
if (!entity) return;
ev.currentTarget.value = "";
if (
this.alertEntities.some((alertEntity) => alertEntity.entity === entity)
) {
return;
}
this._changed([...this.alertEntities, { entity }]);
}
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const next = [...this.alertEntities];
const [moved] = next.splice(oldIndex, 1);
next.splice(newIndex, 0, moved);
this._changed(next);
}
static styles = css`
:host {
display: block;
}
.alert-list {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.alert-row {
display: flex;
align-items: flex-start;
gap: var(--ha-space-2);
}
.handle {
cursor: grab;
color: var(--secondary-text-color);
flex-shrink: 0;
display: flex;
align-items: center;
min-height: 48px;
}
ha-settings-row {
flex: 1;
min-width: 0;
padding: 0;
gap: var(--ha-space-3);
min-height: 48px;
--settings-row-prefix-display: contents;
--settings-row-content-display: contents;
--settings-row-body-padding-top: var(--ha-space-1);
--settings-row-body-padding-bottom: var(--ha-space-1);
}
state-badge {
flex-shrink: 0;
width: 24px;
height: 24px;
--state-icon-color: var(--secondary-text-color);
}
[slot="heading"],
[slot="description"] {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
ha-entity-picker {
display: block;
padding-top: var(--ha-space-3);
}
ha-icon-button {
--ha-icon-button-size: 40px;
}
ha-control-select-menu {
width: 118px;
--control-select-menu-height: 42px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"security-alerts-editor": SecurityAlertsEditor;
}
}
@@ -0,0 +1,236 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-icon";
import type { SecurityFrontendSystemData } from "../../../data/frontend";
import {
internationalizationContext,
registriesContext,
statesContext,
} from "../../../data/context";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { ValueChangedEvent } from "../../../types";
import "../../../components/entity/ha-favorites-editor";
import "../components/security-alerts-editor";
import {
createSecurityEntityFilter,
type SecurityEntityContext,
} from "../security-entity-filter";
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
@customElement("dialog-edit-security")
export class DialogEditSecurity extends DirtyStateProviderMixin<SecurityFrontendSystemData>()(
DialogMixin<EditSecurityDialogParams>(LitElement)
) {
@state() private _state?: SecurityFrontendSystemData;
@state() private _submitting = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
private _entityContext?: SecurityEntityContext;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("_states") || changedProps.has("_registries")) {
this._entityContext = {
states: this._states,
...this._registries,
};
}
}
public connectedCallback(): void {
super.connectedCallback();
if (!this.params) {
return;
}
this._state = {
...this.params.config,
favorite_entities: this.params.config.favorite_entities
? [...this.params.config.favorite_entities]
: [],
alert_entities: this.params.config.alert_entities
? [...this.params.config.alert_entities]
: [],
};
this._initDirtyTracking({ type: "deep" }, this._state);
}
protected render() {
if (!this.params || !this._state) {
return nothing;
}
return html`
<ha-dialog
open
width="medium"
.headerTitle=${this._i18n.localize("ui.panel.security.editor.title")}
.headerSubtitle=${this._i18n.localize(
"ui.panel.security.editor.description"
)}
.preventScrimClose=${this.isDirtyState}
>
${this._renderMainEditor()}
<ha-dialog-footer slot="footer">
<ha-button
autofocus
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
>
${this._i18n.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting || !this.isDirtyState}
>
${this._i18n.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _renderMainEditor() {
return html`
<ha-expansion-panel
outlined
expanded
no-collapse
.header=${this._i18n.localize(
"ui.panel.security.editor.favorite_entities"
)}
.secondary=${this._i18n.localize(
"ui.panel.security.editor.favorite_entities_description"
)}
>
<ha-icon slot="leading-icon" icon="mdi:star-outline"></ha-icon>
<div class="expansion-content">
<ha-favorites-editor
.favorites=${this._state?.favorite_entities ?? []}
.entityFilter=${this._entityFilter}
.addButtonLabel=${this._i18n.localize(
"ui.panel.security.editor.add_favorite_entity"
)}
@value-changed=${this._favoriteEntitiesChanged}
></ha-favorites-editor>
</div>
</ha-expansion-panel>
<ha-expansion-panel
outlined
expanded
no-collapse
.header=${this._i18n.localize(
"ui.panel.security.editor.active_alert_entities"
)}
.secondary=${this._i18n.localize(
"ui.panel.security.editor.active_alert_entities_description"
)}
>
<ha-icon slot="leading-icon" icon="mdi:shield-alert-outline"></ha-icon>
<div class="expansion-content">
<security-alerts-editor
.alertEntities=${this._state?.alert_entities ?? []}
@value-changed=${this._alertEntitiesChanged}
></security-alerts-editor>
</div>
</ha-expansion-panel>
`;
}
private _alertEntitiesChanged(
ev: ValueChangedEvent<SecurityFrontendSystemData["alert_entities"]>
): void {
this._state = {
...this._state,
alert_entities: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private _favoriteEntitiesChanged(
ev: ValueChangedEvent<SecurityFrontendSystemData["favorite_entities"]>
): void {
this._state = {
...this._state,
favorite_entities: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
private async _save(): Promise<void> {
if (!this.params || !this._state) return;
this._submitting = true;
try {
await this.params.saveConfig({
...this.params.config,
favorite_entities: this._state.favorite_entities?.length
? this._state.favorite_entities
: undefined,
alert_entities: this._state.alert_entities?.length
? this._state.alert_entities
: undefined,
});
this._markDirtyStateClean();
this.closeDialog();
} catch {
return;
} finally {
this._submitting = false;
}
}
static styles = [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: var(--ha-space-6);
}
ha-expansion-panel {
display: block;
--expansion-panel-content-padding: 0;
border-radius: var(--ha-border-radius-md);
--ha-card-border-radius: var(--ha-border-radius-md);
}
ha-expansion-panel + ha-expansion-panel {
margin-top: var(--ha-space-4);
}
.expansion-content {
padding: var(--ha-space-3);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-edit-security": DialogEditSecurity;
}
}
@@ -0,0 +1,20 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { SecurityFrontendSystemData } from "../../../data/frontend";
export interface EditSecurityDialogParams {
config: SecurityFrontendSystemData;
saveConfig: (config: SecurityFrontendSystemData) => Promise<void>;
}
export const loadEditSecurityDialog = () => import("./dialog-edit-security");
export const showEditSecurityDialog = (
element: HTMLElement,
params: EditSecurityDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-edit-security",
dialogImport: loadEditSecurityDialog,
dialogParams: params,
});
};
+110 -12
View File
@@ -1,15 +1,24 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemData,
saveFrontendSystemData,
type SecurityFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import { showEditSecurityDialog } from "./dialogs/show-dialog-edit-security";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
@@ -30,10 +39,16 @@ class PanelSecurity extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _config?: SecurityFrontendSystemData;
@state() private _searchParms = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -47,13 +62,22 @@ class PanelSecurity extends LitElement {
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
@@ -63,22 +87,44 @@ class PanelSecurity extends LitElement {
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
await this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemData(
this.hass.connection,
"security"
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || {};
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
// eslint-disable-next-line no-console
console.error("Failed to load security configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.security.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
@@ -97,6 +143,16 @@ class PanelSecurity extends LitElement {
.backButton=${this._searchParms.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.security")}</div>
${
this.hass.user?.is_admin && this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.security.editor.title")}
@click=${this._editSecurity}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
@@ -118,8 +174,18 @@ class PanelSecurity extends LitElement {
}
private async _setLovelace() {
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
SECURITY_LOVELACE_VIEW_CONFIG,
{
strategy: {
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
alert_entities: this._config?.alert_entities,
favorite_entities: this._config?.favorite_entities,
},
},
this.hass
);
@@ -146,6 +212,38 @@ class PanelSecurity extends LitElement {
};
}
private _editSecurity = () => {
if (!this.hass.user?.is_admin || !this._config) {
return;
}
showEditSecurityDialog(this, {
config: this._config,
saveConfig: async (config) => {
await this._saveConfig(config);
},
});
};
private async _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
try {
await saveFrontendSystemData(this.hass.connection, "security", config);
this._config = config;
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to save security configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.security.editor.save_failed"),
duration: 0,
dismissable: true,
});
throw err;
}
showToast(this, {
message: this.hass.localize("ui.common.successfully_saved"),
});
await this._setLovelace();
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -0,0 +1,15 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { registriesContext, statesContext } from "../../data/context";
import { isSecurityPanelEntity } from "./strategies/security-view-strategy";
export type SecurityEntityContext = ContextType<typeof registriesContext> & {
states: ContextType<typeof statesContext>;
};
export const createSecurityEntityFilter =
(getContext: () => SecurityEntityContext | undefined) =>
(entity: HassEntity): boolean => {
const context = getContext();
return context ? isSecurityPanelEntity(context, entity) : false;
};
@@ -0,0 +1,89 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../../../common/entity/compute_domain";
import type {
SecurityAlertEntityConfig,
SecurityAlertSeverity,
} from "../../../data/frontend";
import type { StateCondition } from "../../lovelace/common/validate-condition";
import type { AlertCardConfig } from "../../lovelace/cards/types";
const DANGER_BINARY_SENSOR_DEVICE_CLASSES = [
"carbon_monoxide",
"gas",
"moisture",
"safety",
"smoke",
] as const;
type DangerBinarySensorDeviceClass =
(typeof DANGER_BINARY_SENSOR_DEVICE_CLASSES)[number];
const DANGER_BINARY_SENSOR_DEVICE_CLASS_SET =
new Set<DangerBinarySensorDeviceClass>(DANGER_BINARY_SENSOR_DEVICE_CLASSES);
const isDangerBinarySensorDeviceClass = (
deviceClass: string
): deviceClass is DangerBinarySensorDeviceClass =>
DANGER_BINARY_SENSOR_DEVICE_CLASS_SET.has(
deviceClass as DangerBinarySensorDeviceClass
);
export const computeDefaultSecurityAlertSeverity = (
stateObj?: HassEntity
): SecurityAlertSeverity => {
if (!stateObj) {
return "warning";
}
const domain = computeDomain(stateObj.entity_id);
if (domain === "alarm_control_panel") {
return "alert";
}
if (domain === "binary_sensor") {
const deviceClass = stateObj.attributes.device_class;
return typeof deviceClass === "string" &&
isDangerBinarySensorDeviceClass(deviceClass)
? "alert"
: "warning";
}
return "warning";
};
export const computeDefaultSecurityAlertVisibility = (
entityId: string
): StateCondition[] => {
const condition: StateCondition = {
condition: "state",
entity: entityId,
};
switch (computeDomain(entityId)) {
case "alarm_control_panel":
condition.state = "triggered";
break;
case "cover":
condition.state = "open";
break;
case "lock":
condition.state = ["jammed", "unlocked", "open"];
break;
default:
condition.state = "on";
}
return [condition];
};
export const computeSecurityAlertCardConfig = (
stateObj: HassEntity | undefined,
alertEntity: SecurityAlertEntityConfig
): AlertCardConfig => {
const severity =
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
return {
type: "alert",
entity: alertEntity.entity,
color: severity === "alert" ? "red" : "amber",
visibility: computeDefaultSecurityAlertVisibility(alertEntity.entity),
};
};
@@ -1,3 +1,4 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
@@ -15,12 +16,19 @@ import type {
LovelaceSectionRawConfig,
} from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type { LogbookCardConfig } from "../../lovelace/cards/types";
import type {
LogbookCardConfig,
TileCardConfig,
} from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { computeSecurityAlertCardConfig } from "./security-alerts";
export interface SecurityViewStrategyConfig {
type: "security";
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
}
export const securityEntityFilters: EntityFilter[] = [
@@ -69,6 +77,17 @@ export const securityEntityFilters: EntityFilter[] = [
},
];
export const isSecurityPanelEntity = (
hass: Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>,
stateObj: HassEntity
): boolean =>
securityEntityFilters.some((filter) =>
generateEntityFilter(hass, filter)(stateObj.entity_id)
);
const processAreasForSecurity = (
areaIds: string[],
hass: HomeAssistant,
@@ -132,7 +151,7 @@ const processUnassignedEntities = (
@customElement("security-view-strategy")
export class SecurityViewStrategy extends ReactiveElement {
static async generate(
_config: SecurityViewStrategyConfig,
config: SecurityViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
@@ -149,6 +168,37 @@ export class SecurityViewStrategy extends ReactiveElement {
const entities = findEntities(allEntities, securityFilters);
const favoriteEntities = (config.favorite_entities ?? []).filter(
(entityId) =>
hass.states[entityId] &&
isSecurityPanelEntity(hass, hass.states[entityId])
);
if (favoriteEntities.length > 0) {
sections.push({
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.favorites"
),
heading_style: "title",
},
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
],
});
}
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -242,37 +292,87 @@ export class SecurityViewStrategy extends ReactiveElement {
const logbookEntityIds = [...entities, ...personEntities];
const sidebarSection: LovelaceSectionConfig | undefined =
hasLogbook && logbookEntityIds.length > 0
? {
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.activity"
),
heading_style: "title",
} as LovelaceCardConfig,
{
type: "logbook",
target: {
entity_id: logbookEntityIds,
},
hours_to_show: 24,
grid_options: { columns: 12 },
} satisfies LogbookCardConfig,
],
}
: undefined;
const sidebarSections: LovelaceSectionConfig[] = [];
const alertCards = config.alert_entities?.map((alertEntity) =>
computeSecurityAlertCardConfig(
hass.states[alertEntity.entity],
alertEntity
)
);
if (alertCards?.length) {
sidebarSections.push({
type: "grid",
visibility: [
{
condition: "or",
conditions: alertCards.map((alertCard) => ({
condition: "and",
conditions: alertCard.visibility!,
})),
},
],
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.active_alerts"
),
heading_style: "title",
},
...alertCards.map((alertCard) => ({
...alertCard,
grid_options: { columns: 12 },
})),
] satisfies LovelaceCardConfig[],
});
}
const hasLogbookSection = hasLogbook && logbookEntityIds.length > 0;
if (hasLogbookSection) {
sidebarSections.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.activity"
),
heading_style: "title",
} as LovelaceCardConfig,
{
type: "logbook",
target: {
entity_id: logbookEntityIds,
},
hours_to_show: 24,
grid_options: { columns: 12 },
} satisfies LogbookCardConfig,
],
});
}
return {
type: "sections",
max_columns: 3,
sections: sections,
...(sidebarSection && {
...(sidebarSections.length > 0 && {
sidebar: {
sections: [sidebarSection],
sections: sidebarSections,
...(!hasLogbookSection && alertCards?.length
? {
visibility: [
{
condition: "or" as const,
conditions: alertCards.map((alertCard) => ({
condition: "and" as const,
conditions: alertCard.visibility!,
})),
},
],
}
: {}),
content_label: hass.localize(
"ui.panel.lovelace.strategy.security.devices"
),
+63 -35
View File
@@ -806,6 +806,7 @@
"replace_device": "Replace",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"entities_count_filtered": "{count}/{total} {total, plural,\n one {entity}\n other {entities}\n}",
"target_details": "Target details",
"no_targets": "No targets",
"no_target_found": "No target found for {term}",
@@ -835,8 +836,11 @@
},
"style": "Time format style"
},
"filter-device-classes": {
"caption": "Device class"
"filter-entity-types": {
"caption": "Type",
"no_device_class": "No type",
"expand": "Expand",
"collapse": "Collapse"
},
"subpage-data-table": {
"filters": "Filters",
@@ -930,6 +934,12 @@
"recommended": "Recommended"
}
},
"device-class-picker": {
"device_class": "Device class",
"add": "Add device class",
"no_device_classes": "No device classes available",
"no_match": "No device classes found for {term}"
},
"category-picker": {
"clear": "Clear",
"show_categories": "Show categories",
@@ -2636,6 +2646,25 @@
"learn_more": "Learn more"
}
},
"security": {
"editor": {
"title": "Edit security and safety page",
"description": "Configure your security and safety display preferences.",
"favorite_entities": "[%key:ui::panel::lovelace::editor::strategy::home::favorite_entities%]",
"favorite_entities_description": "Pin entities to the top of the page.",
"add_favorite_entity": "[%key:ui::panel::lovelace::editor::strategy::home::add_favorite_entity%]",
"active_alert_entities": "Active alert entities",
"active_alert_entities_description": "Display any entities that require attention.",
"add_alert_entity": "Add entity",
"severity": {
"label": "Severity",
"alert": "Alert",
"warning": "Warning"
},
"load_failed": "Failed to load security and safety page configuration",
"save_failed": "Failed to save security and safety page configuration"
}
},
"my": {
"not_supported": "This redirect is not supported by your Home Assistant instance. Check the {link} for the supported redirects and the version they where introduced.",
"component_not_loaded": "This redirect is not supported by your Home Assistant instance. You need the integration {integration} to use this redirect.",
@@ -5382,7 +5411,6 @@
"any_state_ignore_attributes": "Any state (ignoring attribute changes)",
"description": {
"picker": "Triggers when the state of an entity (or attribute) changes.",
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}",
"changed": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n}{anyChange, select, \n true { or any attribute} \n other {}\n} changed{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
},
"for_type": {
@@ -5423,9 +5451,6 @@
"type_input": "Value of an entity",
"description": {
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"crossed_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above}{duration, select, \n undefined {} \n other { for {duration}}\n}",
"crossed_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}",
"crossed_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}"
@@ -5680,9 +5705,6 @@
"value_template": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::value_template%]",
"description": {
"picker": "Tests if the numeric value of an entity's state (or attribute's value) is above or below a given threshold.",
"above": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above}",
"below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} below {below}",
"above-below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above} and below {below}",
"is_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above}",
"is_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is below {below}",
"is_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above} and below {below}"
@@ -5701,8 +5723,6 @@
"state": "[%key:ui::panel::config::automation::editor::conditions::type::state::label%]",
"description": {
"picker": "Tests if an entity (or attribute) is in a specific state.",
"no_entity": "If state confirmed",
"full": "If{hasAttribute, select, \n true { {attribute} of}\n other {}\n} {numberOfEntities, plural,\n =0 {an entity is}\n one {{entities} is}\n other {{entities} {matchAny, select,\n true {is}\n other {are}\n}}\n} {numberOfStates, plural,\n =0 {a state}\n other {{states}}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n }",
"is": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n} is {states}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
}
},
@@ -6032,7 +6052,7 @@
},
"check_condition": {
"description": {
"full": "Test {condition}"
"full": "Test: {condition}"
}
},
"set_conversation_response": {
@@ -6415,12 +6435,25 @@
"description_features": "Control home when away and integrate with Alexa and Google Assistant",
"login": {
"title": "Cloud login",
"introduction": "Home Assistant Cloud provides you with a secure remote connection to your instance while away from home. It also allows you to connect with cloud-only services: Amazon Alexa and Google Assistant.",
"introduction2": "This service is run by our partner ",
"introduction2a": ", a company founded by the founders of Home Assistant.",
"introduction3": "Home Assistant Cloud is a subscription service with a free one-month trial. No payment information necessary.",
"learn_more_link": "Learn more about Home Assistant Cloud",
"landing_title": "[%key:ui::panel::config::cloud::account::onboarding::ha_cloud_title%]",
"hero_title": "Tools for your home, support for the Open Home",
"hero_lead": "The easy, secure, and private way to get more out of Home Assistant, and the simplest way to fund its development.",
"trial_note": "Try free for a month. No payment information required up front.",
"partner_note": "This service is run by our partner {nabu_casa_link}, a company founded by the founders of Home Assistant.",
"feature_remote_title": "Access from anywhere",
"feature_remote_body": "Securely reach your Home Assistant from any device, with no port forwarding or VPN.",
"feature_backup_title": "Backups kept safe",
"feature_backup_body": "Your latest Home Assistant backup, stored off-site and encrypted, ready to restore your full system on first boot.",
"feature_voice_control_title": "Native Alexa and Google support",
"feature_voice_control_body": "Control every Home Assistant device through Amazon Alexa and Google Assistant, so the devices and routines you already rely on keep working. Set up in minutes, not hours.",
"feature_voice_quality_title": "Faster speech for your own assistant",
"feature_voice_quality_body": "More languages and higher accuracy for Home Assistant's privacy-first voice assistant.",
"feature_companion_title": "Companion app",
"feature_companion_body": "Reach your home from your phone anywhere, with location and sensor updates flowing back securely and no ports to open.",
"feature_support_title": "Support Home Assistant",
"feature_support_body": "Your subscription funds the Open Home Foundation, keeping Home Assistant independent with no ads or data harvesting.",
"sign_in": "Sign in",
"sign_in_lead": "Sign in to your Nabu Casa account.",
"email": "Email",
"email_error_msg": "Invalid email",
"password": "Password",
@@ -6430,15 +6463,11 @@
"cancel": "Cancel",
"submit": "Submit",
"forgot_password": "Forgot password?",
"start_trial": "Start your free 1-month trial",
"trial_info": "No payment information necessary",
"alert_password_change_required": "You need to change your password before logging in.",
"alert_email_confirm_necessary": "You need to confirm your email before logging in.",
"alert_mfa_code_required": "You need to enter your two-factor authentication code.",
"alert_mfa_expired_or_not_started": "Multi-factor authentication expired, or not started. Please try again.",
"alert_totp_code_invalid": "Invalid two-factor authentication code.",
"cloud_pipeline_title": "Want to use Home Assistant Cloud for your voice assistant?",
"cloud_pipeline_text": "We created a new assistant for you, using the superior text-to-speech and speech-to-text engines from Home Assistant Cloud. Would you like to set this assistant as the preferred assistant?"
"alert_totp_code_invalid": "Invalid two-factor authentication code."
},
"forgot_password": {
"title": "Forgot password",
@@ -6450,17 +6479,9 @@
"check_your_email": "Check your email for instructions on how to reset your password."
},
"register": {
"title": "Register account",
"headline": "Start your free trial",
"headline": "Start free trial",
"information": "Create an account to start your free one-month trial with Home Assistant Cloud. No payment information necessary.",
"information2": "The trial will give you access to all the benefits of Home Assistant Cloud, including:",
"feature_remote_control": "Control of Home Assistant away from home",
"feature_google_home": "Integration with Google Assistant",
"feature_amazon_alexa": "Integration with Amazon Alexa",
"feature_webhook_apps": "Easy integration with webhook-based apps like OwnTracks",
"information3": "This service is run by our partner ",
"information3a": ", a company founded by the founders of Home Assistant.",
"information4": "By registering an account, you agree to the following terms and conditions.",
"terms_notice": "By creating an account, you agree to our {terms} and {privacy_policy}.",
"link_terms_conditions": "Terms and conditions",
"link_privacy_policy": "Privacy policy",
"create_account": "Create account",
@@ -6469,10 +6490,17 @@
"password": "Password",
"password_error_msg": "Passwords are at least 8 characters",
"start_trial": "Start trial",
"sign_in_instead": "Sign in instead",
"check_your_email": "Check your email",
"confirm_email_body": "Open the confirmation link in the email we just sent to {email}.",
"waiting_for_confirmation": "Waiting for confirmation. We'll sign you in automatically.",
"checking_confirmation": "Checking for your confirmation…",
"auto_login_failed": "We couldn't sign you in automatically. Confirm your email, then sign in.",
"nothing_arrived": "Nothing arrived? Check your spam folder, or {resend_link}.",
"resend_link": "resend the confirmation email",
"resend_confirm_email": "Resend confirmation email",
"clicked_confirm": "I opened the confirmation link",
"confirm_email": "Check the email we just sent to {email} and open the confirmation link to continue.",
"account_created": "Account created! Check your email for instructions on how to activate your account.",
"verification_email_sent": "Verification email sent. Check your inbox for instructions on how to activate your account."
},
"account": {
@@ -9157,6 +9185,8 @@
"security": {
"devices": "Devices",
"other_devices": "Other devices",
"active_alerts": "Active alerts",
"favorites": "[%key:ui::panel::lovelace::strategy::home::favorites%]",
"activity": "Activity"
},
"climate": {
@@ -11737,8 +11767,6 @@
"cancel": "[%key:ui::panel::config::cloud::login::cancel%]",
"submit": "[%key:ui::panel::config::cloud::login::submit%]",
"forgot_password": "[%key:ui::panel::config::cloud::login::forgot_password%]",
"start_trial": "[%key:ui::panel::config::cloud::login::start_trial%]",
"trial_info": "[%key:ui::panel::config::cloud::login::trial_info%]",
"alert_password_change_required": "[%key:ui::panel::config::cloud::login::alert_password_change_required%]",
"alert_email_confirm_necessary": "[%key:ui::panel::config::cloud::login::alert_email_confirm_necessary%]",
"alert_mfa_code_required": "[%key:ui::panel::config::cloud::login::alert_mfa_code_required%]",
+30
View File
@@ -17,10 +17,24 @@ const generatePoints = (seed: number, count: number): [number, number][] => {
return points;
};
// The chart data modules break the line with a null value. A handful of them
// stands for an entity that went unavailable; a series that is mostly null
// stands for the climate heating dataset, which emits one per inactive state.
const withGaps = (
points: [number, number][],
isGap: (index: number) => boolean
): [number, number | null][] =>
points.map(([x, y], index) => (isGap(index) ? [x, null] : [x, y]));
const small = generatePoints(1, SCALES.small);
const medium = generatePoints(2, SCALES.medium);
const large = generatePoints(3, SCALES.large);
const largeObjects = large.map((value) => ({ value }));
const largeFewGaps = withGaps(large, (index) => index % 20_000 === 0);
const largeMostlyGaps = withGaps(
large,
(index) => Math.floor(index / 50) % 3 !== 0
);
describe("downSampleLineData", () => {
bench("min/max small (1k points)", () => {
@@ -54,4 +68,20 @@ describe("downSampleLineData", () => {
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large with a few gaps (100k points)",
() => {
downSampleLineData(largeFewGaps, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large mostly gaps (100k points)",
() => {
downSampleLineData(largeMostlyGaps, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
});
@@ -0,0 +1,178 @@
import { describe, expect, it, vi } from "vitest";
import { timeCachePromiseFunc } from "../../../src/common/util/time-cache-function-promise";
describe("timeCachePromiseFunc", () => {
it("reuses an in-flight request when the cache key is unchanged", async () => {
let resolveRequest!: (value: string) => void;
const request = new Promise<string>((resolve) => {
resolveRequest = resolve;
});
const fetchData = vi.fn(() => request);
const hass = { version: 1 };
const calls = Array.from({ length: 20 }, () =>
timeCachePromiseFunc(
"_testCache",
30_000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
)
);
expect(fetchData).toHaveBeenCalledTimes(1);
resolveRequest("result");
await expect(Promise.all(calls)).resolves.toEqual(Array(20).fill("result"));
expect(fetchData).toHaveBeenCalledTimes(1);
});
it("reuses a resolved cached result while the cache key is unchanged", async () => {
const fetchData = vi.fn().mockResolvedValue("result");
const hass = { version: 1 };
const first = await timeCachePromiseFunc(
"_testCache",
30_000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
const second = await timeCachePromiseFunc(
"_testCache",
30_000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
expect(first).toBe("result");
expect(second).toBe("result");
expect(fetchData).toHaveBeenCalledTimes(1);
});
it("refetches a resolved cached result when the cache key changes", async () => {
const fetchData = vi
.fn()
.mockResolvedValueOnce("first")
.mockResolvedValueOnce("second");
const hass = { version: 1 };
const first = await timeCachePromiseFunc(
"_testCache",
30_000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
hass.version = 2;
const second = await timeCachePromiseFunc(
"_testCache",
30_000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
expect(first).toBe("first");
expect(second).toBe("second");
expect(fetchData).toHaveBeenCalledTimes(2);
});
it("reuses an in-flight request without a cache key validator", async () => {
let resolveRequest!: (value: string) => void;
const request = new Promise<string>((resolve) => {
resolveRequest = resolve;
});
const fetchData = vi.fn(() => request);
const hass = {};
const first = timeCachePromiseFunc(
"_testCacheNoValidator",
30_000,
fetchData,
undefined,
hass
);
const second = timeCachePromiseFunc(
"_testCacheNoValidator",
30_000,
fetchData,
undefined,
hass
);
expect(fetchData).toHaveBeenCalledTimes(1);
resolveRequest("result");
await expect(first).resolves.toBe("result");
await expect(second).resolves.toBe("result");
expect(fetchData).toHaveBeenCalledTimes(1);
});
});
describe("timeCachePromiseFunc cache ownership", () => {
it("does not let an older cache timer clear a newer cached result", async () => {
vi.useFakeTimers();
try {
const fetchData = vi
.fn()
.mockResolvedValueOnce("first")
.mockResolvedValueOnce("second")
.mockResolvedValueOnce("third");
const hass = { version: 1 };
await timeCachePromiseFunc(
"_testTimerOwnership",
1000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
await vi.advanceTimersByTimeAsync(500);
hass.version = 2;
await timeCachePromiseFunc(
"_testTimerOwnership",
1000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
expect(fetchData).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(500);
const result = await timeCachePromiseFunc(
"_testTimerOwnership",
1000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
expect(result).toBe("second");
expect(fetchData).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});

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