Compare commits

..
Author SHA1 Message Date
WendelinandClaude Opus 5.5 5e96fac4dc Keep errors on duplicate rows and rows changed while saving
Identical rows keep their own errors after the YAML is parsed again, and
a save whose config changed meanwhile checks the current config in full,
so rows renamed and moved during the save stay marked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 08:53:41 +01:00
WendelinandClaude Opus 5.5 1fa476ab67 Follow error rows through moves and saves
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:13:08 +01:00
WendelinandClaude Opus 5.5 0a4a354e22 Show automation validation errors on the rows they belong to
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 16:27:05 +01:00
Markus TuominenandClaude Fable 5.1 95a009641f Fix integration icons being clipped into circles on the area page (#55820)
Stop rounding and distorting integration icons on the area page

The generic img rule meant for the area picture also applied to the 24px
brand icons in the device list, clipping square integration logos (for
example Awair) into circles. Scope that rule to the area picture and size
the icons with width/height attributes like the device page does.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-10 10:17:22 +01:00
Wendelin 36f59f8495 Pass callWS to entity and device registry update helpers (#55819) 2026-11-10 11:02:12 +02:00
Stefan AgnerandClaude Opus 5.5 8a13bba521 Only match an OTBR to a Thread network it is part of (#55803)
* Only match an OTBR to a Thread network it is part of

findOTBRInfoForDataset returned the dataset's preferred border router
without checking that it is still on that network. When the border
router had moved to another network, the Thread panel offered to reset
it from the card of a network it no longer belongs to, which would
replace the border router's current network.

Require the extended PAN ID to match for the preferred border router
too, and drop the active dataset check in the network information page
that worked around the same problem.

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

* Update findOTBRInfoForDataset tests for the network check

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

* Keep findOTBRInfoForDataset fallback tests independent of key order

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-10 11:00:30 +02:00
Maarten Lakerveld cc7b1952c0 End slider and color wheel interaction when a drag is cancelled (#55804)
End slider and color wheel interaction when a pan is cancelled

ha-control-slider and ha-hs-color-picker restored their value on
pancancel without firing an event, so consumers never learned that the
interaction ended. The light color pickers then kept ignoring state
updates until the next interaction. Fire the same end-of-interaction
event as panend (and as ha-control-scrubber already does), and restore
the wheel's local value along with its cursor position.
2026-11-10 10:59:38 +02:00
karwosts 2edf6f91d0 Allow configurable time format for timer.finishes_at in state-display (#55813)
Allow custom time format for timer.finishes_at
2026-11-10 09:26:27 +01:00
jimjaeger 9d52517ae9 Retry translation fragments after a failed request (#55716)
* Retry translation fragments after a failed request

* Clear translation mock before each test
2026-11-10 08:51:58 +02:00
renovate[bot] 27a7f6335f Update dependency @vvo/tzdb to v6.203.0 (#55814)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-10 08:25:58 +02:00
renovate[bot] cd44bfcebc Update dependency eslint-plugin-import-x to v4.18.0 (#55815)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-10 08:25:55 +02:00
Elias Lecomte 8819844740 Fix single-day selection in the date range picker (#55718)
* Fix single-day selection in the date range picker

* Limit single-tap date selection to devices without hover
2026-11-10 08:23:21 +02:00
WendelinandClaude Opus 5.5 008adf1533 Move the create helper dialog and helper forms to contexts (#55794)
* Narrow helper create and area update data functions to the API they use

The create helper dialog can then pass the API context instead of the
full hass object.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eHviQBgc6SiiNrMfyY

* Consume contexts in helper forms instead of hass

The helper forms only read translations, locale and config, so they now
consume the internationalization and config contexts. This avoids
rerendering them on every unrelated hass change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eHviQBgc6SiiNrMfyY

* Consume contexts in the create helper dialog instead of hass

The dialog reads translations, config, the API and the connection from
their contexts and no longer passes hass to the helper forms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eHviQBgc6SiiNrMfyY

* Refactor API calls to use HomeAssistantApi for improved type safety and consistency across data handling

---------

Co-authored-by: Claude <[email protected]>
2026-11-09 20:54:31 +01:00
renovate[bot] 8c3d0f2f1e Update dependency serve to v14.2.7 (#55810)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-09 20:52:23 +01:00
renovate[bot] 3f4f89480f Update tsparticles to v4.5.3 (#55811)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-09 19:51:19 +00:00
Matthias de BaatandClaude Opus 5.5 432762a2ea Add design to the AI policy (#55795)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-09 20:45:01 +01:00
WendelinandClaude Opus 5.5 013cf8d7e6 Give automation editor rows a config path (#55801)
* Give automation editor rows a config path

Each list and row in the automation and script editor extends the path
of its ancestor with its own segment through a Lit context, e.g.
["actions", 0, "then", 1]. Rows that open in the sidebar register in a
per-editor registry by path, as groundwork to find the row a config path
belongs to, e.g. to point at an error in it.

Move the generic path type and helpers from the Lovelace editor to
common/util/config-path and use ConfigPath everywhere.

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

* Test config path helpers on their own

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-09 20:17:34 +02:00
Joost LekkerkerkerandClaude 1de489cca9 Sort device connections by type in device info card (#55797)
Claude-Session: https://claude.ai/code/session_01SLHDn4zYGvMHi4C8v3m45c

Co-authored-by: Claude <[email protected]>
2026-11-09 16:58:30 +01:00
Maarten Lakerveld 97af661779 Use the Backups menu caption as the backup page title (#55799)
The Settings > System menu said "Backups" while the page header said
"Backup". Reuse the caption key like the other system pages do, so
the two can't drift apart again.
2026-11-09 16:41:09 +01:00
Erwin Douna f702e369fc Add a health dialog to config entries (#55736)
* Add a health dialog to config entries

* Address review feedback

* Address review feedback

* Address review feedback

* Address review feedback

* Use the integration filter for the logs link
2026-11-09 16:40:04 +01:00
Maarten Lakerveld 78e0a92ddd Include disabled helpers in the helpers area filter (#55796)
The helpers table lists disabled helpers, but its area filter left them
out, so a disabled helper without an area disappeared when picking
No area, and one in an area disappeared when picking that area. Match
the entities page, which already passes include-disabled-entities.
2026-11-09 16:24:11 +01:00
Andreas d742259444 Fix stale color and slider jump after quick drag in light color pickers (#55780)
* Fix stale color sent after quick drag in light color pickers

When the color temperature slider is dragged and released within the
500 ms throttle window, the release handler skipped sending because the
picker value already equalled the final value, relying on the pending
throttled call. A state update from the leading throttled call then
reset the picker value (no longer interacting), so the trailing call
sent the stale initial value and the light ended up at the wrong color
temperature.

Cancel the pending throttled call on release and always send the final
value. Apply the same cancel to the RGB picker, where a pending trailing
call could send a stale color after the final one.

* Keep released value in light color pickers until state settles

After release, keep showing the released value for a short time and
ignore state updates in between, so that late state updates from calls
sent while dragging no longer make the slider jump back before the
final value is reported.

* Don't sync light color pickers while a new drag is active

If a new drag starts before the release hold of the previous one ends,
the hold timer must not overwrite the picker value with the reported
state, as that makes the control jump and lets a pending throttled call
send the older value. The release of the new drag starts a new hold.

* Address review: keep brightness normalization in sync, hold native input

Keep the brightness normalization of RGB-only lights in sync with the
reported state while the picker values are held (during a drag or the
release hold), as it is applied to the next color sent. Otherwise
successive color selections could dim the light repeatedly.

The native color input has no release event and sends throttled, so
refresh the release hold on every input. This keeps state updates from
calls sent while picking from overwriting the pending value.

* Use PendingValueSync in light color pickers and end interaction on release

Replace the fixed release hold timer with PendingValueSync, so state
updates are ignored only while a call is queued or in flight. Also clear
_isInteracting in the release handlers, so a keyboard adjustment or a
cancelled pan no longer stops the picker from following the state.
2026-11-09 16:22:09 +01:00
OllieandClaude Opus 5.5 623ca14356 Configure remote Bluetooth adapters from the adapters page (#55742)
* Configure remote Bluetooth adapters from the adapters page

Remote scanners such as ESPHome Bluetooth proxies have their scanning
mode and other settings in the options flow of the integration that
provides them. Show the configure button for remote adapters on the
Bluetooth adapters page and open that integration's options flow, found
through the scanner device's via device, so users no longer have to find
the device under Devices & services.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ALzdAQkZAy37oGWd7GMtbQ

* Prefer the providing integration's device when finding the source entry

Other integrations may add config entries to the scanner's own device,
so look at the via device first and only fall back to the scanner's
device.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ALzdAQkZAy37oGWd7GMtbQ

* Only use the via device and skip disabled source entries

Drop the fallback to the scanner's own device, which could pick up an
unrelated integration's entry, and don't offer to configure a remote
adapter whose providing config entry is disabled or has no options flow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ALzdAQkZAy37oGWd7GMtbQ

* Check for a configurable source entry in the template

Keep _getSourceConfigEntry a plain lookup, as suggested in review, and
skip the cog for disabled or non-configurable source entries where it
is rendered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ALzdAQkZAy37oGWd7GMtbQ

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-09 16:10:41 +01:00
187 changed files with 4951 additions and 2143 deletions

No files matched your search

+8
View File
@@ -28,10 +28,18 @@ AI can be a helpful tool for writing code and documentation. However, due to the
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
## Design contributions
AI can be a helpful tool to explore ideas and build mockups and prototypes. But AI tends to give the most common answer, not the one that fits Home Assistant and its users. So we require a human who brings the point of view and owns the design decisions.
You should be able to explain the problem your design solves and why you made your choices. If a mockup or prototype was mostly generated with AI, say so when you share it. Check your work against the [design guidelines](https://contributors.home-assistant.io/design/), as AI tools often make up components, colors, or spacing that don't exist in Home Assistant. Design proposals that look like unreviewed AI output may be closed or hidden.
## Our use of AI
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
We also use AI to help keep the design system, the Figma kit, and the code in sync. The design decisions behind them are made by people.
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
## Enforcement
+5 -5
View File
@@ -77,11 +77,11 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.24",
"@tsparticles/engine": "4.5.2",
"@tsparticles/preset-links": "4.5.2",
"@tsparticles/engine": "4.5.3",
"@tsparticles/preset-links": "4.5.3",
"@versatiles/style": "8.1.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.202.0",
"@vvo/tzdb": "6.203.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"barcode-detector": "3.2.3",
"cally": "0.9.2",
@@ -180,7 +180,7 @@
"eslint": "10.14.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-import-x": "4.18.0",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.3",
"eslint-plugin-unused-imports": "4.4.1",
@@ -208,7 +208,7 @@
"pinst": "3.0.0",
"prettier": "3.9.10",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"serve": "14.2.7",
"sinon": "22.1.0",
"tar": "7.5.22",
"terser-webpack-plugin": "5.6.1",
+138 -192
View File
@@ -291,11 +291,11 @@ importers:
specifier: 0.5.24
version: 0.5.24
'@tsparticles/engine':
specifier: 4.5.2
version: 4.5.2
specifier: 4.5.3
version: 4.5.3
'@tsparticles/preset-links':
specifier: 4.5.2
version: 4.5.2
specifier: 4.5.3
version: 4.5.3
'@versatiles/style':
specifier: 8.1.1
version: 8.1.1([email protected])
@@ -303,8 +303,8 @@ importers:
specifier: 4.0.4
version: 4.0.4
'@vvo/tzdb':
specifier: 6.202.0
version: 6.202.0
specifier: 6.203.0
version: 6.203.0
'@webcomponents/scoped-custom-element-registry':
specifier: 0.0.10
version: 0.0.10
@@ -595,8 +595,8 @@ importers:
specifier: 0.13.11
version: 0.13.11([email protected])
eslint-plugin-import-x:
specifier: 4.17.1
version: 4.17.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])
specifier: 4.18.0
version: 4.18.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])
eslint-plugin-lit:
specifier: 2.3.1
version: 2.3.1([email protected]([email protected]))
@@ -679,8 +679,8 @@ importers:
specifier: 5.2.2
version: 5.2.2(@rspack/[email protected](@swc/[email protected]))
serve:
specifier: 14.2.6
version: 14.2.6([email protected])
specifier: 14.2.7
version: 14.2.7([email protected])
sinon:
specifier: 22.1.0
version: 22.1.0
@@ -3270,85 +3270,85 @@ packages:
resolution: {integrity: sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==}
engines: {node: '>=12'}
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-bwvu84lMX5Sp79pyKu5yW2SFAqY52B7OlaxDjeeP3TiO7hJLFWxd8VPmSfOarcYK/z/uxp/dZfbcvp/5ylXyiQ==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-Fz2bTDc6Folav8isOk2scXM+LCSPhQ9hD8xCSmsEGuGH7JkE+XTsS4j6E9H6b+RFlZ1yb2TxdVBJnfXy8XM9Bw==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-lA+GP2EN5uN3TnOMEFqYBS0yLuQyZ2UlHy72TedgREGeEsrUoXB/FtZKseVt0g9FId4ZI6jXizxEavs65PbUsA==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-f0gHWnqChK4ksE9Bq3YN0iuJVMi2B81YSkCL5BrohuykoaKCLSVVSqjhefmELTMrdiFVsdt6WPJ+HjVQgjt7CA==}
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-NaxB1X/90lSu+uI0ayaaNC4okLIwJ93nBWvrdYHQ5/xsyan01qU+rnDjdg8Y9fPewncXlHkOaQezHeG4RjDubg==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-kbZoUA2uqR2043FHfH2+KFz7JhYTZjyTeDqVL/Z3z38mfdHEVrOJ4q+mwYjr+hX4tq2YoLf1D4DA6w/er1AlKA==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-JmaqBMZW/OzXGeQBz1g67EwTKeOYccIFrQMnBmiZgavO7LLhglABpyTWJAT9Cb7M/kgx3GIecZGkPxFzQ44pxQ==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-ak8AAPQKinJlwztRAo+OA5ItwjLtymmjayefQCF6K1FIWs5SaWiHWbzMB5vdOvuGUE6OOchqj8NLcu3HfHmdjw==}
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-kNwh59Oyi8lvFQYK9z4YgAhS0GcOfFLkIhafTo0qlklqSUl3CvZq0aS14t368oz4Qk/l29pvnLJQg8pmRxIMaw==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-NAm9yL8o3oaeYgB7WLzuoD0yfLw9kvEwu0N+UHTwoe+kYRLJvmoUyMlED7hPQHW74VTlSmuZ6gqWTnaOv1mFLA==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/plugin-interactivity': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/plugin-interactivity': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-7mO9tpJvXkbRIn4OjjUNoiPKAEJkEeazLl8eQB9W9lYpxuNCyZZ+sXJpS2LazyBNKTvGy1P0ekoX/rHqc84vKA==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-fJJp0xVEeGQDpkjGzvftMbCrLWtwqo+ees49gCnr9SZYVMRbxi3vQYukvi4ULOzYgEW5bTseVprDfiqOwfQJcw==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-gzcPgOzb3vP3p1AF/op5045buNYjsUOHoeTx9WHABrbDDVrQYUo1KsThg6Uo6Uahd8yMIuWUDlzjgF722KBn0w==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-k+AkTG8r2UTE3n2u4kmb9dODRVtBc3Yttk7/UQ5HPlcBMr8AnKbx9gyxzzCx2MaEFZG2My0cghJPvQvibrAW7Q==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-/gIqWofLBYLcYcEGt1jxZPD+kgCJOoajS22NxxbVi80MrUxvT586qqbhYiiAesBxRUgCZOJnK39BF2S4A0Ik7Q==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-jfyHjPvrc8ORwaiIJ3xgzIveRhlImn21TiblpakeF2Ft5HgxJ+Lb8eIN/2VUJFjdqLpQ3H1nGSJspHYzeU5RtQ==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-uzZGjqxFhz/LUAKnbBYApgelXBrXSJt25fad8TxL5RoDVGgd22pxPErwEtCTAbhJyL6Z5Clj9p5vk9Vve+sodg==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-4w1m8WFL5UJElwTO978P9fOub2vtLCYRLD0YpmXY+AumZw02wLRH6Al7xY2Sgchbgm/uLHkCDzeUTYUi9GzURg==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-Oh2FPN26M+JlSFvLr7rOJEyTO7n0AUu5x3gHCx4mX9Dqm0JQsL0T17nUMc8pra/nHOemiK3W+dCIm0Y7/8GQiQ==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-kyriGuLkOu5uVv3cHRacJRk8XMm9gga7OzCuf3oxbgfqqIaazva4Qw8N19RCpVCW1EfwyWFGq3h5VD/QplkA0w==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-y6s2yant8vBdvIw6chTlPVxf9T5lzdmpSyEsI6gX3obTSaJyhw6nbfl6iWcQNx1jjgp4dYVhEf8PneNMqC8VQw==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-lKlI9BaHXy+L30d2CKWWn3B9Ye7LOqRHav8rzXcxAQMfZxj4iK1UuGRT+27/KzQMZBYMPd9G4R7x7lV8RngFdQ==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-WH8dfjSMWa5uCzheemqy+UzQXIpIymWVrnBOK3L3eBusYFhAg3L0ChBhjKBDjPDlKOquoeRrdt5D8ebgBjlY8g==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-U21Aj8pPjxW7wEH1Itm7Ik1rBO5rTXyxKWLjZXEDlT1agXxcwCVbL/5rffUQzbeRFAk/t4KiUGi4rTvAS62wTQ==}
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-D6sKXKLPZZ45csOfj3mUQ1bzBgO5mJw7qfpAIYLlk7H+slrHlIoOwxwWgwcuBTY7JJ0jLgiq2062yLHxjEfdeA==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-jtJAqqnltG12YCy7VpTtIIEOlRNmVDz11SQrZlxFUHWgIYa0UcTEUDeLpP4h9IW7KtnxJKBo/xu1JLRq4tY6fw==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-/mJkwVoFZZl4TtkPmAhdZYuiSjaegJ4zs5l7YJdwKPnN7/6xmvJwrQhj9UGQHwusyUBZ5/V+6cXQFEG7Em154Q==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-VaWHjSacuvcDpUPKriQMeG4xzNTKRuK7tZJL0toDQdB4uei2LX5qUBUqTrI0oBWW5Rk0QdUZR83LkhCLVJWtew==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-vpSXG73F0lFMsb1uwgut7SiuFNF0Hk5QRD7i8DJO5oyyTrSkrHUjQHFFL7ONb+zmPzLC1J8SlfHXkldJZlpKww==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-gx9cV9LutgY0f25AQtFqW285jGCsHpAxYKbmoN4MOUEYOVX9WiDfrf59Ta9c/SelLvOanVsu5V1hVzBsJyX37w==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-6EXelSVOv0LQrAretlDMIxMV/e1+GfJftBfPsHuCTPrTbmcoEIHQRC9VsGQDS/k4ija6oA2GTqTvoni8Ztbk8A==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-I+tPJgq7M5Qcu4pX97J1Hfo0s+bnCmj32IiZ3AJTFFA/+xScaL0SF7T7tW5YtXR45ZwYjaSdnmQ80CwrLxJjdQ==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
resolution: {integrity: sha512-XwcyTnRssZ+ssuL500ojFaMdR5w+eb9XVs7MHd2G8q3A5N5a2UAKIhExiTEbWyAGXSgfHo/LQxUTX2Lq1cxSRg==}
'@tsparticles/[email protected].3':
resolution: {integrity: sha512-hh62wuc4HZ+BydUC62bA74hgr+qV888iWm23V222d6rqIkOpYzi5tcoPAh48Mgjl2gMSigTE6sySJez7reffNQ==}
peerDependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tufjs/[email protected]':
resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==}
@@ -3864,8 +3864,8 @@ packages:
'@vscode/[email protected]':
resolution: {integrity: sha512-2ZUIRfhofZ/npLlf872EBnPmn27Kt4M2UssmQIfnJvgGgMYZJ5fvtHEDnttBBf2hnVtBgNCqZMVHJA+wsFVqTA==}
'@vvo/[email protected]2.0':
resolution: {integrity: sha512-MGsedU65ypk/Y7yrp+oOLBzhQj/124xvyoUzx7csPiBSf1b3lgTp5imatuWU4SF6s1Ue3I6GxC+GxE8R5O1l0w==}
'@vvo/[email protected]3.0':
resolution: {integrity: sha512-VdAiMSmHFzdL2csQU4QtNEV0l4JOgMPdFvk7cEm4a5LRVZsM3tQ2Kxyk5O6plB55DjOwIyoTt29mFi+vA9GYSw==}
'@web/[email protected]':
resolution: {integrity: sha512-XVKH79pk4d3EHRhofete8eAnqto1e8mCRAqPV00KLNFzCWSe8sWmLnqKCqkPNARC6nksMaGrATnA5sPDRllMpQ==}
@@ -4050,9 +4050,6 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
[email protected]:
resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==}
[email protected]:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
@@ -4400,6 +4397,9 @@ packages:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==}
[email protected]:
resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
engines: {node: '>= 10.0'}
@@ -4420,10 +4420,6 @@ packages:
resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==}
engines: {node: '>=18.20'}
[email protected]:
resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
[email protected]:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
@@ -4909,6 +4905,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
[email protected]:
resolution: {integrity: sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==}
[email protected]:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
@@ -4962,8 +4961,8 @@ packages:
eslint-plugin-import: '>=1.4.0'
webpack: '>=1.11.0'
[email protected]7.1:
resolution: {integrity: sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==}
[email protected]8.0:
resolution: {integrity: sha512-UfxHFmV+f9vwybrNn6LQ1rPzfeDMPGi/dRbqQnCFO/jBzYrujSOyX7jxqBSYr/HwXs/V4XJdEGPvC3Re46nzKg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/utils': ^8.56.0
@@ -5067,10 +5066,6 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
[email protected]:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==}
@@ -5288,10 +5283,6 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'}
@@ -5498,10 +5489,6 @@ packages:
resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==}
engines: {node: '>= 20'}
[email protected]:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
[email protected]:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
@@ -5638,11 +5625,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
[email protected]:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
engines: {node: '>= 0.4'}
@@ -5792,10 +5774,6 @@ packages:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
@@ -6435,10 +6413,6 @@ packages:
resolution: {integrity: sha512-vzc1svxw/kw1IRjFsLi6gaxe1Olqm88V0tIfu2u5raL0b1gChe6ZEXNkyUlKxUC7s/egt5NxZHkbY18tMKKLfQ==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
[email protected]:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@@ -7040,8 +7014,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==}
[email protected].6:
resolution: {integrity: sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==}
[email protected].7:
resolution: {integrity: sha512-mTJ9LAyBwHhO2T/PNaI0oWxmKl7FFuJ+ZEHKL1rRO69fWV1Y4qcsFB3gpuD2HfHvJCwvJq5ChibjjAGe0wc0vw==}
engines: {node: '>= 14'}
hasBin: true
@@ -7295,10 +7269,6 @@ packages:
resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
@@ -7439,6 +7409,10 @@ packages:
resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==}
engines: {node: '>=20.0.0'}
[email protected]:
resolution: {integrity: sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==}
engines: {node: ^16.14.0 || >= 17.3.0}
[email protected]:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
@@ -11118,88 +11092,88 @@ snapshots:
magic-string: 0.30.21
string.prototype.matchall: 4.0.12
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
'@tsparticles/[email protected].3':
dependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/plugin-blend': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/plugin-hex-color': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/plugin-hsl-color': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/plugin-move': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/plugin-rgb-color': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/shape-circle': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/updater-opacity': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/updater-out-modes': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/updater-paint': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/updater-size': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/engine': 4.5.3
'@tsparticles/plugin-blend': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/plugin-hex-color': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/plugin-hsl-color': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/plugin-move': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/plugin-rgb-color': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/shape-circle': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/updater-opacity': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/updater-out-modes': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/updater-paint': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/updater-size': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2': {}
'@tsparticles/[email protected].3': {}
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)(@tsparticles/[email protected].2(@tsparticles/[email protected].2))':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)(@tsparticles/[email protected].3(@tsparticles/[email protected].3))':
dependencies:
'@tsparticles/canvas-utils': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/engine': 4.5.2
'@tsparticles/plugin-interactivity': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/canvas-utils': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/engine': 4.5.3
'@tsparticles/plugin-interactivity': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2':
'@tsparticles/[email protected].3':
dependencies:
'@tsparticles/basic': 4.5.2
'@tsparticles/engine': 4.5.2
'@tsparticles/interaction-particles-links': 4.5.2(@tsparticles/[email protected].2)(@tsparticles/[email protected].2(@tsparticles/[email protected].2))
'@tsparticles/plugin-interactivity': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/basic': 4.5.3
'@tsparticles/engine': 4.5.3
'@tsparticles/interaction-particles-links': 4.5.3(@tsparticles/[email protected].3)(@tsparticles/[email protected].3(@tsparticles/[email protected].3))
'@tsparticles/plugin-interactivity': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/animation-utils': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/engine': 4.5.2
'@tsparticles/animation-utils': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/engine': 4.5.2
'@tsparticles/engine': 4.5.3
'@tsparticles/[email protected].2(@tsparticles/[email protected].2)':
'@tsparticles/[email protected].3(@tsparticles/[email protected].3)':
dependencies:
'@tsparticles/animation-utils': 4.5.2(@tsparticles/[email protected].2)
'@tsparticles/engine': 4.5.2
'@tsparticles/animation-utils': 4.5.3(@tsparticles/[email protected].3)
'@tsparticles/engine': 4.5.3
'@tufjs/[email protected]': {}
@@ -11647,7 +11621,7 @@ snapshots:
'@vscode/[email protected]': {}
'@vvo/[email protected]2.0': {}
'@vvo/[email protected]3.0': {}
'@web/[email protected]':
dependencies:
@@ -11786,8 +11760,6 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.2
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -12165,6 +12137,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
source-map: 0.6.1
@@ -12179,12 +12153,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
arch: 2.2.0
execa: 5.1.1
is-wsl: 2.2.0
[email protected]:
dependencies:
string-width: 4.2.3
@@ -12693,6 +12661,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
es-errors: 1.3.0
@@ -12751,15 +12721,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected]7.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected]):
[email protected]8.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected]):
dependencies:
'@typescript-eslint/types': 8.72.1
cjs-module-lexer: 2.3.0
comment-parser: 1.4.8
debug: 4.4.3([email protected])
es-module-lexer: 3.0.4
eslint: 10.14.0([email protected])
eslint-import-context: 0.1.9([email protected])
is-glob: 4.0.3
minimatch: 10.2.6
minimatch: 11.0.5
semver: 7.8.5
stable-hash-x: 0.2.0
unrs-resolver: 1.12.2
@@ -12884,18 +12856,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
cross-spawn: 7.0.6
get-stream: 6.0.1
human-signals: 2.1.0
is-stream: 2.0.1
merge-stream: 2.0.0
npm-run-path: 4.0.1
onetime: 5.1.2
signal-exit: 3.0.7
strip-final-newline: 2.0.0
[email protected]: {}
[email protected]:
@@ -13145,8 +13105,6 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
[email protected]: {}
[email protected]:
dependencies:
call-bound: 1.0.4
@@ -13419,8 +13377,6 @@ snapshots:
- kerberos
- supports-color
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -13550,8 +13506,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
call-bound: 1.0.4
@@ -13674,10 +13628,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
is-docker: 2.2.1
[email protected]: {}
[email protected]: {}
@@ -14368,10 +14318,6 @@ snapshots:
- kerberos
- supports-color
[email protected]:
dependencies:
path-key: 3.1.1
[email protected]:
dependencies:
boolbase: 1.0.0
@@ -15042,7 +14988,7 @@ snapshots:
path-to-regexp: 3.3.0
range-parser: 1.2.0
[email protected].6([email protected]):
[email protected].7([email protected]):
dependencies:
'@zeit/schemas': 2.36.0
ajv: 8.18.0
@@ -15050,10 +14996,10 @@ snapshots:
boxen: 7.0.0
chalk: 5.0.1
chalk-template: 0.4.0
clipboardy: 3.0.0
compression: 1.8.1([email protected])
is-port-reachable: 4.0.0
serve-handler: 6.1.7
tinyclip: 0.1.15
update-check: 1.5.4
transitivePeerDependencies:
- supports-color
@@ -15397,8 +15343,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -15514,6 +15458,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
+23
View File
@@ -0,0 +1,23 @@
/** Where a value is in a config, e.g. `["actions", 0, "then", 1]`. */
export type ConfigPath = (string | number)[];
export const stringifyPath = (path: ConfigPath): string => path.join("/");
export const pathEquals = (a: ConfigPath, b: ConfigPath): boolean =>
a.length === b.length && a.every((segment, index) => segment === b[index]);
export const pathStartsWith = (path: ConfigPath, prefix: ConfigPath): boolean =>
prefix.length <= path.length &&
prefix.every((segment, index) => segment === path[index]);
/** Formats a path the way it reads in YAML, e.g. `actions[1].then[0].delay`. */
export const formatPath = (path: ConfigPath): string =>
path
.map((segment, index) =>
typeof segment === "number"
? `[${segment}]`
: index === 0
? segment
: `.${segment}`
)
.join("");
@@ -30,6 +30,9 @@ export class HaAutomationRow extends LitElement {
@property({ type: Boolean, reflect: true })
public dim = false;
@property({ type: Boolean, reflect: true })
public error = false;
@query(".row")
private _rowElement?: HTMLDivElement;
@@ -232,6 +235,15 @@ export class HaAutomationRow extends LitElement {
:host([highlight]) .row:hover {
background-color: rgba(var(--rgb-primary-color), 0.16);
}
:host([error]) .row {
background-color: var(--ha-color-fill-danger-quiet-resting);
}
:host([error][selected]) .row,
:host([error][selected]) .row:focus {
outline: 2px solid var(--ha-color-border-danger-loud);
outline-offset: -2px;
background-color: var(--ha-color-fill-danger-normal-resting);
}
.icons,
.leading-icon-wrapper,
@@ -97,6 +97,8 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
@state() private _dateValue?: string;
@state() private _rangeIncomplete = false;
@state() private _timeValue = {
from: { hours: 0, minutes: 0 },
to: { hours: 23, minutes: 59 },
@@ -195,6 +197,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
.locale=${this._i18n.locale.language}
.focusedDate=${this._focusDate}
@focusday=${this._focusChanged}
@rangestart=${this._handleRangeStart}
@change=${this._handleChange}
show-outside-days
.firstDayOfWeek=${firstWeekdayIndex(this._i18n.locale)}
@@ -253,7 +256,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<ha-button appearance="plain" @click=${this._cancel}
>${this._i18n.localize("ui.common.cancel")}</ha-button
>
<ha-button .disabled=${!this._dateValue} @click=${this._save}
<ha-button
.disabled=${!this._dateValue || this._rangeIncomplete}
@click=${this._save}
>${this._i18n.localize(
"ui.components.date-range-picker.select"
)}</ha-button
@@ -280,7 +285,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
private _save() {
if (!this._dateValue) {
if (!this._dateValue || this._rangeIncomplete) {
return;
}
@@ -331,9 +336,21 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._focusDate = ev.target.focusedDate;
}
private _handleRangeStart(
ev: HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
this._rangeIncomplete = matchMedia("(hover: hover)").matches;
if (this._rangeIncomplete) {
return;
}
const day = ev.target.tentative;
this._dateValue = `${day}/${day}`;
}
private _handleChange(
ev: HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
this._rangeIncomplete = false;
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = dateElement.focusedDate;
@@ -14,6 +14,12 @@ class HaDeviceActionPicker extends HaDeviceAutomationPicker<DeviceAction> {
);
}
protected get NOT_SUPPORTED_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.actions.not_supported"
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
@@ -49,6 +49,10 @@ export abstract class HaDeviceAutomationPicker<
@property({ type: Object }) public value?: T;
@property({ type: Boolean }) public invalid = false;
@property({ attribute: "error-message" }) public errorMessage?: string;
@state() private _automations?: T[];
// Trigger an empty render so we start with a clean DOM.
@@ -106,6 +110,13 @@ export abstract class HaDeviceAutomationPicker<
);
}
/** Error shown when the picked device has nothing to choose from. */
protected get NOT_SUPPORTED_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.actions.not_supported"
);
}
private _localizeDeviceAutomation: (
localize: LocalizeFunc,
states: HassEntities,
@@ -163,6 +174,12 @@ export abstract class HaDeviceAutomationPicker<
.value=${value}
.disabled=${this._automations?.length === 0}
.loading=${this._automations === undefined}
.invalid=${this.invalid}
.errorMessage=${
this._automations?.length === 0
? this.NOT_SUPPORTED_TEXT
: this.errorMessage
}
.getItems=${this._getItems(
value,
this._automations,
@@ -14,6 +14,12 @@ class HaDeviceConditionPicker extends HaDeviceAutomationPicker<DeviceCondition>
);
}
protected get NOT_SUPPORTED_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.conditions.not_supported"
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.conditions.unknown_condition"
@@ -107,6 +107,10 @@ export class HaDevicePicker extends LitElement {
@property() public helper?: string;
@property({ type: Boolean }) public invalid = false;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
@@ -521,6 +525,8 @@ export class HaDevicePicker extends LitElement {
.autofocus=${this.autofocus}
.disabled=${this.disabled}
.helper=${this.helper}
.invalid=${this.invalid}
.errorMessage=${this.errorMessage}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${this._notFoundLabel}
@@ -14,6 +14,12 @@ class HaDeviceTriggerPicker extends HaDeviceAutomationPicker<DeviceTrigger> {
);
}
protected get NOT_SUPPORTED_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.triggers.not_supported"
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.triggers.unknown_trigger"
-4
View File
@@ -62,10 +62,6 @@ export class HaControlSelect extends LitElement {
});
}
public override focus(): void {
this._focusOption(this._tabbableIndex);
}
private _handleBlur(ev: FocusEvent) {
// Only reset if focus is leaving the entire component
if (!this.contains(ev.relatedTarget as Node)) {
+1
View File
@@ -219,6 +219,7 @@ export class HaControlSlider extends LitElement {
this.pressed = false;
this._hideTooltip();
this.value = savedValue;
fireEvent(this, "slider-moved", { value: undefined });
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
+4
View File
@@ -214,15 +214,19 @@ class HaHsColorPicker extends LitElement {
this._mc.add(new Tap({ event: "singletap" }));
let savedPosition;
let savedValue: [number, number] | undefined;
this._mc.on("panstart", (e) => {
if (this.disabled) return;
this._pressed = e.pointerType;
savedPosition = this._cursorPosition;
savedValue = this._localValue;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = savedPosition;
this._localValue = savedValue;
fireEvent(this, "cursor-moved", { value: undefined });
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
@@ -21,6 +21,8 @@ export class HaActionSelector extends LitElement {
@property({ attribute: false }) public value?: Action;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -51,6 +53,7 @@ export class HaActionSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-action
.pathKey=${this.name}
.disabled=${this.disabled}
.actions=${this._actions(this.value)}
.hass=${this.hass}
@@ -16,6 +16,8 @@ export class HaConditionSelector extends LitElement {
@property({ attribute: false }) public value?: Condition;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -27,6 +29,7 @@ export class HaConditionSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-condition
.pathKey=${this.name}
.disabled=${this.disabled}
.conditions=${this.value || []}
.hass=${this.hass}
@@ -17,6 +17,8 @@ export class HaTriggerSelector extends LitElement {
@property({ attribute: false }) public value?: Trigger;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -32,6 +34,7 @@ export class HaTriggerSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-trigger
.pathKey=${this.name}
.disabled=${this.disabled}
.triggers=${this._triggers(this.value)}
.hass=${this.hass}
+98 -29
View File
@@ -41,6 +41,7 @@ import "./ha-selector/ha-selector";
import type { HaSelector } from "./ha-selector/ha-selector";
import "./ha-service-picker";
import "./ha-service-section-icon";
import "./ha-input-helper-text";
import "./ha-settings-row";
import "./ha-yaml-editor";
import type { HaYamlEditor } from "./ha-yaml-editor";
@@ -119,6 +120,15 @@ export class HaServiceControl extends LitElement {
@property({ attribute: "hide-description", type: Boolean })
public hideDescription = false;
/** Error shown below the target picker. */
@property({ attribute: false }) public targetError?: string;
/** Errors shown below the data fields, keyed by field. */
@property({ attribute: false }) public dataErrors?: Record<string, string>;
/** Error of the data as a whole. */
@property({ attribute: false }) public dataError?: string;
@state() private _value!: this["value"];
@state() private _checkedKeys = new Set();
@@ -542,13 +552,14 @@ export class HaServiceControl extends LitElement {
${
serviceData && "target" in serviceData
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector>`
class="target-selector"
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector>
${this._renderError(this.targetError)}`
: entityId
? html`<ha-entity-picker
.disabled=${this.disabled}
@@ -594,7 +605,10 @@ export class HaServiceControl extends LitElement {
this._hasFilteredFields(fields, targetEntities)
? html`<ha-expansion-panel
left-chevron
.expanded=${!dataField.collapsed}
.expanded=${
!dataField.collapsed ||
fields.some((field) => this.dataErrors?.[field.key])
}
.header=${
this.hass.localize(
`component.${domain}.services.${serviceName}.sections.${dataField.key}.name`,
@@ -626,7 +640,12 @@ export class HaServiceControl extends LitElement {
</ha-expansion-panel>`
: nothing;
})
} `;
}
${this._unshownErrors(
serviceData,
targetEntities,
Boolean(shouldRenderServiceDataYaml)
).map((error) => this._renderError(error))}`;
}
private _getSectionDescription(
@@ -690,6 +709,25 @@ export class HaServiceControl extends LitElement {
? this.hass.services[domain][serviceName].description_placeholders
: undefined;
const fieldError = this.dataErrors?.[dataField.key];
const selectorTemplate = html`<ha-selector
.context=${this._selectorContext(targetEntities)}
.disabled=${
this.disabled ||
(showOptional &&
!this._checkedKeys.has(dataField.key) &&
(!this._value?.data || this._value.data[dataField.key] === undefined))
}
.hass=${this.hass}
.selector=${selector}
.key=${dataField.key}
@value-changed=${this._serviceDataChanged}
.value=${this._value?.data ? this._value.data[dataField.key] : undefined}
.placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback}
.required=${dataField.required}
></ha-selector>`;
return dataField.selector
? html`<ha-settings-row
.narrow=${
@@ -742,30 +780,50 @@ export class HaServiceControl extends LitElement {
}
></ha-markdown>
</span>
<ha-selector
.context=${this._selectorContext(targetEntities)}
.disabled=${
this.disabled ||
(showOptional &&
!this._checkedKeys.has(dataField.key) &&
(!this._value?.data ||
this._value.data[dataField.key] === undefined))
}
.hass=${this.hass}
.selector=${selector}
.key=${dataField.key}
@value-changed=${this._serviceDataChanged}
.value=${
this._value?.data ? this._value.data[dataField.key] : undefined
}
.placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback}
.required=${dataField.required}
></ha-selector>
<div class="field">
${selectorTemplate}${this._renderError(fieldError)}
</div>
</ha-settings-row>`
: "";
};
// Errors without a field to show them on, e.g. of a field no target
// supports or of a target the action doesn't have a picker for
private _unshownErrors(
serviceData: ExtHassService | undefined,
targetEntities: string[] | null,
dataYaml: boolean
): string[] {
const targetShown = Boolean(serviceData && "target" in serviceData);
const shownFields = new Set(
dataYaml
? []
: (serviceData?.flatFields ?? [])
.filter(
(field) =>
field.selector &&
(!field.filter ||
this._filterField(field.filter, targetEntities))
)
.map((field) => field.key)
);
return [
...(this.targetError && !targetShown ? [this.targetError] : []),
...(this.dataError ? [this.dataError] : []),
...Object.entries(this.dataErrors ?? {})
.filter(([key]) => !shownFields.has(key))
.map(([key, error]) => `${key}: ${error}`),
];
}
private _renderError(error: string | undefined) {
return error
? html`<ha-input-helper-text class="error"
>${error}</ha-input-helper-text
>`
: nothing;
}
private _selectorContext = memoizeOne((targetEntities: string[] | null) => ({
filter_entity: targetEntities || undefined,
}));
@@ -991,6 +1049,17 @@ export class HaServiceControl extends LitElement {
ha-settings-row {
padding: var(--service-control-padding, 0 var(--ha-space-4));
}
/* Every field has this wrapper, so the selector isn't rebuilt when its
error comes or goes */
.field {
display: flex;
flex-direction: column;
}
ha-input-helper-text.error {
color: var(--ha-color-on-danger-quiet);
padding-top: var(--ha-space-1);
padding-bottom: var(--ha-space-2);
}
ha-settings-row[narrow] {
padding-bottom: var(--ha-space-2);
}
+70
View File
@@ -1,5 +1,13 @@
import type { ConfigPath } from "../common/util/config-path";
import type { HomeAssistant } from "../types";
export interface ConfigValidationError {
message: string;
/** Where the problem is, `[]` when core does not know. */
path: ConfigPath;
code?: string | null;
}
export interface ValidConfig {
valid: true;
error: null;
@@ -8,10 +16,14 @@ export interface ValidConfig {
export interface InvalidConfig {
valid: false;
error: string;
/** Paths start at the item index of the section. */
errors: ConfigValidationError[];
}
type ValidKeys = "triggers" | "actions" | "conditions";
export type ValidationSectionKeys = Partial<Record<ValidKeys, string>>;
export const validateConfig = <T extends Partial<Record<ValidKeys, unknown>>>(
hass: HomeAssistant,
config: T
@@ -20,3 +32,61 @@ export const validateConfig = <T extends Partial<Record<ValidKeys, unknown>>>(
type: "validate_config",
...config,
});
/**
* Flattens a `validate_config` result into errors with paths from the config
* root. `sectionKeys` names the config key of a section when it differs from
* the key it is validated under, e.g. `{ actions: "sequence" }` for scripts.
*/
export const validationResultErrors = (
result: Partial<Record<ValidKeys, ValidConfig | InvalidConfig>>,
sectionKeys: ValidationSectionKeys = {}
): ConfigValidationError[] =>
(Object.keys(result) as ValidKeys[]).flatMap((key) => {
const section = result[key];
if (!section || section.valid) {
return [];
}
const sectionKey = sectionKeys[key] ?? key;
return section.errors.map((error) => ({
...error,
path: [sectionKey, ...error.path],
}));
});
export const validateConfigErrors = async (
hass: HomeAssistant,
config: Partial<Record<ValidKeys, unknown>>,
sectionKeys?: ValidationSectionKeys
): Promise<ConfigValidationError[]> =>
validationResultErrors(await validateConfig(hass, config), sectionKeys);
export interface ConfigSaveError {
message: string;
errors: ConfigValidationError[];
}
/**
* Reads the errors of a failed config save. Errors that are not about the
* config, like a network error, only have a message.
*/
export const parseConfigSaveError = (err: unknown): ConfigSaveError => {
const { body, error } = (err ?? {}) as { body?: unknown; error?: unknown };
const bodyObject =
body && typeof body === "object"
? (body as { message?: unknown; errors?: unknown })
: undefined;
const message = String(
bodyObject?.message ||
error ||
(typeof body === "string" && body) ||
(err instanceof Error && err.message) ||
err
);
return {
message,
errors: Array.isArray(bodyObject?.errors)
? (bodyObject.errors as ConfigValidationError[])
: [],
};
};
+128
View File
@@ -0,0 +1,128 @@
import type { HassEntities } from "home-assistant-js-websocket";
import type { ConfigEntry } from "./config_entries";
import type { DataEntryFlowProgress } from "./data_entry_flow";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import type { IntegrationSetup } from "./integration";
import type { RepairsIssue } from "./repairs";
import type { LoggedError } from "./system_log";
import { getLoggedErrorIntegration } from "./system_log";
export type HealthStatus = "ok" | "warning" | "error" | "info";
export type OverallHealthStatus = Exclude<HealthStatus, "info">;
export interface ConfigEntryHealth {
status: OverallHealthStatus;
state: HealthStatus;
entities: {
status: HealthStatus;
total: number;
unavailable: EntityRegistryEntry[];
};
repairs: { status: HealthStatus; issues: RepairsIssue[] };
logs: { status: HealthStatus; errors: number; warnings: number };
flows: { status: HealthStatus; flows: DataEntryFlowProgress[] };
setupSeconds?: number;
}
const ATTENTION_SOURCES = ["reauth", "reconfigure"];
const STATE_STATUS: Record<ConfigEntry["state"], HealthStatus> = {
loaded: "ok",
setup_in_progress: "info",
unload_in_progress: "info",
not_loaded: "info",
setup_retry: "warning",
setup_error: "error",
migration_error: "error",
failed_unload: "error",
};
const worst = (statuses: HealthStatus[]): OverallHealthStatus =>
statuses.includes("error")
? "error"
: statuses.includes("warning")
? "warning"
: "ok";
export const computeConfigEntryHealth = (
entry: ConfigEntry,
entities: EntityRegistryEntry[],
states: HassEntities,
issues: RepairsIssue[],
log: LoggedError[],
flows: DataEntryFlowProgress[],
setups: IntegrationSetup[],
// Library loggers from the manifest, so dependency errors count too.
loggers: string[] = []
): ConfigEntryHealth => {
const enabled = entities.filter((entity) => !entity.disabled_by);
// Entities without a state yet (e.g. while the entry is not loaded) count as unavailable.
const unavailable = enabled.filter(
(entity) =>
!states[entity.entity_id] ||
states[entity.entity_id].state === "unavailable"
);
const entryIssues = issues.filter(
(issue) =>
!issue.ignored && (issue.issue_domain ?? issue.domain) === entry.domain
);
const entryLog = log.filter(
(item) =>
getLoggedErrorIntegration(item) === entry.domain ||
loggers.some(
(logger) => item.name === logger || item.name.startsWith(`${logger}.`)
)
);
const sumCount = (levels: LoggedError["level"][]) =>
entryLog
.filter((item) => levels.includes(item.level))
.reduce((total, item) => total + item.count, 0);
const errors = sumCount(["error", "critical"]);
const warnings = sumCount(["warning"]);
const entryFlows = flows.filter(
(flow) =>
flow.handler === entry.domain &&
flow.context.entry_id === entry.entry_id &&
ATTENTION_SOURCES.includes(flow.context.source)
);
// A disabled entry that failed to unload still needs a restart.
const state: HealthStatus =
entry.disabled_by && entry.state !== "failed_unload"
? "info"
: STATE_STATUS[entry.state];
const health: ConfigEntryHealth = {
status: "ok",
state,
entities: {
status: entry.disabled_by || !unavailable.length ? "ok" : "warning",
total: enabled.length,
unavailable,
},
repairs: {
status: entryIssues.some((issue) => issue.severity !== "warning")
? "error"
: entryIssues.length
? "warning"
: "ok",
issues: entryIssues,
},
logs: {
status: errors ? "error" : warnings ? "warning" : "ok",
errors,
warnings,
},
flows: { status: entryFlows.length ? "error" : "ok", flows: entryFlows },
setupSeconds: setups.find((setup) => setup.domain === entry.domain)
?.seconds,
};
health.status = worst([
state,
health.entities.status,
health.repairs.status,
health.logs.status,
health.flows.status,
]);
return health;
};
+3 -3
View File
@@ -1,6 +1,6 @@
import type { Connection } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type {
DataEntryFlowProgress,
DataEntryFlowStep,
@@ -78,10 +78,10 @@ export const deleteConfigFlow = (
// The backend indexes a single key, and only these two exist.
export const getConfigFlowHandlers = (
hass: HomeAssistant,
callApi: HomeAssistantApi["callApi"],
type?: "helper" | "integration"
) =>
hass.callApi<string[]>(
callApi<string[]>(
"GET",
`config/config_entries/flow_handlers${type ? `?type=${type}` : ""}`
);
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
export interface Counter {
id: string;
@@ -25,10 +25,10 @@ export const fetchCounter = (hass: HomeAssistant) =>
hass.callWS<Counter[]>({ type: "counter/list" });
export const createCounter = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: CounterMutableParams
) =>
hass.callWS<Counter>({
callWS<Counter>({
type: "counter/create",
...values,
});
+3 -3
View File
@@ -1,6 +1,6 @@
import type { Connection } from "home-assistant-js-websocket";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, HomeAssistantApi } from "../../types";
import type { ConfigEntry } from "../config_entries";
import type {
EntityRegistryDisplayEntry,
@@ -231,11 +231,11 @@ export const devicesInArea = (devices: DeviceRegistryEntry[], areaId: string) =>
devices.filter((device) => device.area_id === areaId);
export const updateDeviceRegistryEntry = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
deviceId: string,
updates: Partial<DeviceRegistryEntryMutableParams>
) =>
hass.callWS<DeviceRegistryEntry>({
callWS<DeviceRegistryEntry>({
type: "config/device_registry/update",
device_id: deviceId,
...updates,
+9 -9
View File
@@ -5,7 +5,7 @@ import memoizeOne from "memoize-one";
import { computeDomain } from "../../common/entity/compute_domain";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { debounce } from "../../common/util/debounce";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, HomeAssistantApi } from "../../types";
import type { LightColor } from "../light";
import type { NextNamePart, RegistryEntry } from "../registry";
import type { Segment } from "../vacuum";
@@ -284,11 +284,11 @@ export const getExtendedEntityRegistryEntries = (
});
export const updateEntityRegistryEntry = (
hass: Pick<HomeAssistant, "callWS">,
callWS: HomeAssistantApi["callWS"],
entityId: string,
updates: Partial<EntityRegistryEntryUpdateParams>
): Promise<UpdateEntityRegistryEntryResult> =>
hass.callWS({
callWS({
type: "config/entity_registry/update",
entity_id: entityId,
...updates,
@@ -388,11 +388,11 @@ export interface AutomaticEntityIdOverrides {
}
export const getAutomaticEntityIds = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
entity_ids: string[],
overrides?: Record<string, AutomaticEntityIdOverrides>
) =>
hass.callWS<Record<string, string | null>>({
callWS<Record<string, string | null>>({
type: "config/entity_registry/get_automatic_entity_ids",
entity_ids,
...(overrides && { overrides }),
@@ -403,22 +403,22 @@ export const getAutomaticEntityIds = (
* the area. Returns the entity ID of each entity after the change.
*/
export const updateEntitiesArea = async (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
entityIds: string[],
areaId: string
): Promise<Record<string, string>> => {
await Promise.all(
entityIds.map((entityId) =>
updateEntityRegistryEntry(hass, entityId, { area_id: areaId })
updateEntityRegistryEntry(callWS, entityId, { area_id: areaId })
)
);
const newEntityIds = await getAutomaticEntityIds(hass, entityIds);
const newEntityIds = await getAutomaticEntityIds(callWS, entityIds);
const result: Record<string, string> = {};
for (const entityId of entityIds) {
const newEntityId = newEntityIds[entityId];
if (newEntityId) {
// eslint-disable-next-line no-await-in-loop
await updateEntityRegistryEntry(hass, entityId, {
await updateEntityRegistryEntry(callWS, entityId, {
new_entity_id: newEntityId,
});
}
-6
View File
@@ -49,17 +49,12 @@ export interface SecurityAlertEntityConfig {
export interface SecurityFrontendSystemData {
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
hidden_entities?: string[];
}
export interface MaintenanceFrontendSystemData {
hidden_entities?: string[];
}
export interface ClimateFrontendSystemData {
hidden_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),
@@ -79,7 +74,6 @@ declare global {
energy: EnergyFrontendSystemData;
security: SecurityFrontendSystemData;
maintenance: MaintenanceFrontendSystemData;
climate: ClimateFrontendSystemData;
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
export interface InputBoolean {
id: string;
@@ -17,10 +17,10 @@ export const fetchInputBoolean = (hass: HomeAssistant) =>
hass.callWS<InputBoolean[]>({ type: "input_boolean/list" });
export const createInputBoolean = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: InputBooleanMutableParams
) =>
hass.callWS<InputBoolean>({
callWS<InputBoolean>({
type: "input_boolean/create",
...values,
});
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
export interface InputButton {
id: string;
@@ -15,10 +15,10 @@ export const fetchInputButton = (hass: HomeAssistant) =>
hass.callWS<InputButton[]>({ type: "input_button/list" });
export const createInputButton = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: InputButtonMutableParams
) =>
hass.callWS<InputButton>({
callWS<InputButton>({
type: "input_button/create",
...values,
});
+2 -2
View File
@@ -45,10 +45,10 @@ export const fetchInputDateTime = (hass: HomeAssistant) =>
hass.callWS<InputDateTime[]>({ type: "input_datetime/list" });
export const createInputDateTime = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: InputDateTimeMutableParams
) =>
hass.callWS<InputDateTime>({
callWS<InputDateTime>({
type: "input_datetime/create",
...values,
});
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
export interface InputNumber {
id: string;
@@ -29,10 +29,10 @@ export const fetchInputNumber = (hass: HomeAssistant) =>
hass.callWS<InputNumber[]>({ type: "input_number/list" });
export const createInputNumber = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: InputNumberMutableParams
) =>
hass.callWS<InputNumber>({
callWS<InputNumber>({
type: "input_number/create",
...values,
});
+3 -3
View File
@@ -2,7 +2,7 @@ import type {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
interface InputSelectEntityAttributes extends HassEntityAttributeBase {
options: string[];
@@ -41,10 +41,10 @@ export const fetchInputSelect = (hass: HomeAssistant) =>
hass.callWS<InputSelect[]>({ type: "input_select/list" });
export const createInputSelect = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: InputSelectMutableParams
) =>
hass.callWS<InputSelect>({
callWS<InputSelect>({
type: "input_select/create",
...values,
});
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
export interface InputText {
id: string;
@@ -36,10 +36,10 @@ export const fetchInputText = (hass: HomeAssistant) =>
hass.callWS<InputText[]>({ type: "input_text/list" });
export const createInputText = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: InputTextMutableParams
) =>
hass.callWS<InputText>({
callWS<InputText>({
type: "input_text/create",
...values,
});
+2 -2
View File
@@ -196,8 +196,8 @@ export const fetchIntegrationManifest = (
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
.then(sanitizeManifest);
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
export const fetchIntegrationSetups = (callWS: HomeAssistant["callWS"]) =>
callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
export const fetchIntegrationLogInfo = (conn) =>
conn.sendMessagePromise({
+3 -3
View File
@@ -1,9 +1,9 @@
import type { Connection, HassEventBase } from "home-assistant-js-websocket";
import { getCollection } from "home-assistant-js-websocket";
import type { ConfigPath } from "../common/util/config-path";
import type { HuiBadge } from "../panels/lovelace/badges/hui-badge";
import type { HuiCard } from "../panels/lovelace/cards/hui-card";
import type { HuiSection } from "../panels/lovelace/sections/hui-section";
import type { LovelacePath } from "../panels/lovelace/editor/lovelace-path";
import type { Lovelace } from "../panels/lovelace/types";
import type { HomeAssistant } from "../types";
import type { LovelaceSectionConfig } from "./lovelace/config/section";
@@ -21,7 +21,7 @@ export interface LovelaceViewElement extends HTMLElement {
narrow?: boolean;
// Temporary compatibility: custom view layouts still read the view index
index?: number;
path?: LovelacePath;
path?: ConfigPath;
cards?: HuiCard[];
badges?: HuiBadge[];
sections?: HuiSection[];
@@ -34,7 +34,7 @@ export interface LovelaceSectionElement extends HTMLElement {
hass?: HomeAssistant;
lovelace?: Lovelace;
preview?: boolean;
path?: LovelacePath;
path?: ConfigPath;
cards?: HuiCard[];
badges?: HuiBadge[];
sections?: HuiSection[];
+3 -2
View File
@@ -21,8 +21,9 @@ export const findOTBRInfoForDataset = (
}
const preferredAddress = dataset.preferred_extended_address;
if (preferredAddress && otbrInfo[preferredAddress]) {
return otbrInfo[preferredAddress];
const preferred = preferredAddress ? otbrInfo[preferredAddress] : undefined;
if (preferred?.extended_pan_id === dataset.extended_pan_id) {
return preferred;
}
return Object.values(otbrInfo).find(
+2 -2
View File
@@ -15,7 +15,7 @@ export const regenerateEntityIds = async (
hass: HomeAssistant,
entities: string[]
): Promise<void> => {
const entityIdsMapping = await getAutomaticEntityIds(hass, entities);
const entityIdsMapping = await getAutomaticEntityIds(hass.callWS, entities);
const entityIdsEntries = Object.entries(entityIdsMapping);
@@ -95,7 +95,7 @@ export const regenerateEntityIds = async (
entityIdsEntries
.filter(([oldId, newId]) => newId && oldId !== newId)
.forEach(([oldEntityId, newEntityId]) =>
updateEntityRegistryEntry(hass, oldEntityId, {
updateEntityRegistryEntry(hass.callWS, oldEntityId, {
new_entity_id: newEntityId!,
}).catch((err: any) => {
showAlertDialog(element, {
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
export const weekdays = [
"sunday",
@@ -33,10 +33,10 @@ export const fetchSchedule = (hass: HomeAssistant) =>
hass.callWS<Schedule[]>({ type: "schedule/list" });
export const createSchedule = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: ScheduleMutableParams
) =>
hass.callWS<Schedule>({
callWS<Schedule>({
type: "schedule/create",
...values,
});
+2 -2
View File
@@ -23,8 +23,8 @@ export interface LoggedError {
first_occurred: number;
}
export const fetchSystemLog = async (hass: HomeAssistant) => {
const log = await hass.callWS<LoggedError[]>({ type: "system_log/list" });
export const fetchSystemLog = async (callWS: HomeAssistant["callWS"]) => {
const log = await callWS<LoggedError[]>({ type: "system_log/list" });
for (const error of log) {
error.level = error.level.toLowerCase() as LoggedError["level"];
}
+6 -3
View File
@@ -11,7 +11,7 @@ import secondsToDuration from "../common/datetime/seconds_to_duration";
import { formatNumericDuration } from "../common/datetime/format_duration";
import type { FormatEntityStateFunc } from "../common/translations/entity-state";
import type { HaDurationData } from "../components/ha-duration-input";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type { FrontendLocaleData } from "./translation";
export type TimerEntity = HassEntityBase & {
@@ -48,8 +48,11 @@ export interface TimerMutableParams {
export const fetchTimer = (hass: HomeAssistant) =>
hass.callWS<Timer[]>({ type: "timer/list" });
export const createTimer = (hass: HomeAssistant, values: TimerMutableParams) =>
hass.callWS<Timer>({
export const createTimer = (
callWS: HomeAssistantApi["callWS"],
values: TimerMutableParams
) =>
callWS<Timer>({
type: "timer/create",
...values,
});
@@ -303,7 +303,7 @@ class StepFlowCreateEntry extends LitElement {
if (Object.keys(this._deviceUpdate).length) {
const deviceUpdates = Object.entries(this._deviceUpdate).map(
([deviceId, update]) =>
updateDeviceRegistryEntry(this.hass, deviceId, {
updateDeviceRegistryEntry(this.hass.callWS, deviceId, {
name_by_user: update.name,
area_id: update.area,
}).catch((err: unknown) => {
@@ -333,14 +333,14 @@ class StepFlowCreateEntry extends LitElement {
});
const entityIdsMapping = await getAutomaticEntityIds(
this.hass,
this.hass.callWS,
entityIds
);
Object.entries(entityIdsMapping).forEach(([oldEntityId, newEntityId]) => {
if (newEntityId) {
entityUpdates.push(
updateEntityRegistryEntry(this.hass, oldEntityId, {
updateEntityRegistryEntry(this.hass.callWS, oldEntityId, {
new_entity_id: newEntityId,
}).catch((err) =>
showAlertDialog(this, {
@@ -423,7 +423,7 @@ class StepFlowCreateEntry extends LitElement {
): Promise<ExtEntityRegistryEntry[]> {
try {
const newEntityIds = await updateEntitiesArea(
this.hass,
this.hass.callWS,
this._entitiesWithoutDevice(entities).map((entity) => entity.entity_id),
areaId
);
@@ -146,7 +146,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
}
const result = await updateEntityRegistryEntry(
this._api,
this._api.callWS,
this.entry.entity_id,
{
options_domain: "cover",
@@ -75,7 +75,7 @@ export class HaMoreInfoLightFavoriteColors extends LitElement {
private async _save(newFavoriteColors: LightColor[]): Promise<void> {
const result = await updateEntityRegistryEntry(
this._api,
this._api.callWS,
this.entry!.entity_id,
{
options_domain: "light",
@@ -15,6 +15,7 @@ import {
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { PendingValueSync } from "../../../../common/util/pending-value-sync";
import { throttle } from "../../../../common/util/throttle";
import "../../../../components/ha-hs-color-picker";
import "../../../../components/ha-icon";
@@ -59,6 +60,8 @@ class LightRgbColorPicker extends LitElement {
@state() private _isInteracting?: boolean;
private _pendingSync = new PendingValueSync();
protected render() {
if (!this.stateObj) {
return nothing;
@@ -181,23 +184,29 @@ class LightRgbColorPicker extends LitElement {
`;
}
private _updateBrightnessAdjusted() {
const stateObj = this.stateObj;
this._brightnessAdjusted = undefined;
if (
stateObj.attributes.color_mode === LightColorMode.RGB &&
stateObj.attributes.rgb_color &&
!lightSupportsColorMode(stateObj, LightColorMode.RGBWW) &&
!lightSupportsColorMode(stateObj, LightColorMode.RGBW)
) {
const maxVal = Math.max(...stateObj.attributes.rgb_color);
if (maxVal < 255) {
this._brightnessAdjusted = maxVal;
}
}
}
private _updateSliderValues() {
const stateObj = this.stateObj;
if (stateObj.state === "on") {
this._brightnessAdjusted = undefined;
if (
stateObj.attributes.color_mode === LightColorMode.RGB &&
stateObj.attributes.rgb_color &&
!lightSupportsColorMode(stateObj, LightColorMode.RGBWW) &&
!lightSupportsColorMode(stateObj, LightColorMode.RGBW)
) {
const maxVal = Math.max(...stateObj.attributes.rgb_color);
if (maxVal < 255) {
this._brightnessAdjusted = maxVal;
}
}
this._updateBrightnessAdjusted();
this._wvSliderValue =
stateObj.attributes.color_mode === LightColorMode.RGBW &&
@@ -235,11 +244,23 @@ class LightRgbColorPicker extends LitElement {
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (this._isInteracting || !changedProps.has("stateObj")) {
if (!changedProps.has("stateObj")) {
return;
}
this._updateSliderValues();
const oldStateObj = changedProps.get("stateObj") as LightEntity | undefined;
if (oldStateObj?.entity_id !== this.stateObj?.entity_id) {
this._throttleUpdateColor.cancel();
this._pendingSync.reset();
this._isInteracting = false;
this._updateSliderValues();
} else if (!this._isInteracting && this._pendingSync.shouldSync()) {
this._updateSliderValues();
} else if (this.stateObj.state === "on") {
// Keep the picker values, but the brightness normalization has to
// follow the reported state, as it is applied to the next color sent.
this._updateBrightnessAdjusted();
}
}
private _hsColorCursorMoved(ev: CustomEvent) {
@@ -250,6 +271,7 @@ class LightRgbColorPicker extends LitElement {
return;
}
this._hsPickerValue = color;
this._pendingSync.schedule();
this._throttleUpdateColor();
}
@@ -312,13 +334,23 @@ class LightRgbColorPicker extends LitElement {
this._colorBrightnessSliderValue = hsv[2] / 2.55;
}
this._pendingSync.schedule();
this._throttleUpdateColor();
}
private _hsColorChanged(ev: CustomEvent) {
this._isInteracting = false;
if (!ev.detail.value) {
// Nothing is sent, so stop blocking state updates.
this._throttleUpdateColor.cancel();
this._pendingSync.reset();
this._updateSliderValues();
return;
}
// A pending throttled call would otherwise send an older color after the
// final one.
this._throttleUpdateColor.cancel();
this._hsPickerValue = ev.detail.value;
this._updateColor();
@@ -366,11 +398,15 @@ class LightRgbColorPicker extends LitElement {
private _applyColor(color: LightColor, params?: Record<string, any>) {
fireEvent(this, "color-changed", color);
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
...color,
...params,
});
return this._pendingSync.run(
() =>
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
...color,
...params,
}),
() => this._updateSliderValues()
);
}
private _colorBrightnessSliderChanged(ev: CustomEvent) {
@@ -13,6 +13,7 @@ import {
} from "../../../../common/color/convert-light-color";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stateColorCss } from "../../../../common/entity/state_color";
import { PendingValueSync } from "../../../../common/util/pending-value-sync";
import { throttle } from "../../../../common/util/throttle";
import "../../../../components/ha-control-slider";
import {
@@ -66,6 +67,8 @@ class LightColorTempPicker extends LitElement {
@state() private _isInteracting?: boolean;
private _pendingSync = new PendingValueSync();
protected render() {
if (!this.stateObj) {
return nothing;
@@ -125,11 +128,19 @@ class LightColorTempPicker extends LitElement {
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (this._isInteracting || !changedProps.has("stateObj")) {
if (!changedProps.has("stateObj")) {
return;
}
this._updateSliderValues();
const oldStateObj = changedProps.get("stateObj");
if (oldStateObj?.entity_id !== this.stateObj?.entity_id) {
this._throttleUpdateColorTemp.cancel();
this._pendingSync.reset();
this._isInteracting = false;
this._updateSliderValues();
} else if (!this._isInteracting && this._pendingSync.shouldSync()) {
this._updateSliderValues();
}
}
private _ctColorCursorMoved(ev: CustomEvent) {
@@ -142,6 +153,7 @@ class LightColorTempPicker extends LitElement {
}
this._ctPickerValue = ct;
this._pendingSync.schedule();
this._throttleUpdateColorTemp();
}
@@ -153,7 +165,16 @@ class LightColorTempPicker extends LitElement {
private _ctColorChanged(ev: CustomEvent) {
const ct = ev.detail.value;
if (isNaN(ct) || this._ctPickerValue === ct) {
this._isInteracting = false;
// A pending throttled call would otherwise send an older value after the
// final one.
this._throttleUpdateColorTemp.cancel();
if (isNaN(ct)) {
// Nothing is sent, so stop blocking state updates.
this._pendingSync.reset();
this._updateSliderValues();
return;
}
@@ -170,11 +191,15 @@ class LightColorTempPicker extends LitElement {
private _applyColor(color: LightColor, params?: Record<string, any>) {
fireEvent(this, "color-changed", color);
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
...color,
...params,
});
return this._pendingSync.run(
() =>
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
...color,
...params,
}),
() => this._updateSliderValues()
);
}
static get styles(): CSSResultGroup {
@@ -112,7 +112,7 @@ export class HaMoreInfoTimerPresets extends LitElement {
};
const result = await updateEntityRegistryEntry(
this._api,
this._api.callWS,
this.entry.entity_id,
{
options_domain: "timer",
@@ -82,7 +82,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
};
try {
await updateEntityRegistryEntry(this.hass, this.params.entityId, {
await updateEntityRegistryEntry(this.hass.callWS, this.params.entityId, {
options_domain: "vacuum",
options: options,
});
@@ -110,7 +110,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
currentOptions.favorite_positions = this._favoritePositions;
const result = await updateEntityRegistryEntry(
this._api,
this._api.callWS,
this.entry.entity_id,
{
options_domain: "valve",
+1 -1
View File
@@ -132,7 +132,7 @@ const copyFavoriteOptionsToEntities = async (
if (selected?.entity) {
const result = await Promise.allSettled(
selected.entity.map((entityId: string) =>
updateEntityRegistryEntry(hass, entityId, {
updateEntityRegistryEntry(hass.callWS, entityId, {
options_domain: domain,
options,
})
+1 -1
View File
@@ -531,7 +531,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
const result = await updateEntityRegistryEntry(
this.hass,
this.hass.callWS,
favoritesContext.entry.entity_id,
{
options_domain: favoritesHandler.domain,
@@ -50,7 +50,7 @@ export class HaVoiceAssistantSetupStepArea extends LitElement {
});
return;
}
await updateDeviceRegistryEntry(this.hass, this.deviceId, {
await updateDeviceRegistryEntry(this.hass.callWS, this.deviceId, {
area_id: area,
});
this._nextStep();
@@ -351,7 +351,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
private async _done() {
if (this._deviceName) {
try {
updateDeviceRegistryEntry(this.hass, this.deviceId, {
updateDeviceRegistryEntry(this.hass.callWS, this.deviceId, {
name_by_user: this._deviceName,
});
} catch (error: any) {
+14 -156
View File
@@ -1,28 +1,18 @@
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 type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type ClimateFrontendSystemData,
} 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 "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
import "../../components/ha-top-app-bar-fixed";
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
@@ -40,20 +30,10 @@ class PanelClimate extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _config?: ClimateFrontendSystemData;
@state() private _editVisibility = false;
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -67,22 +47,13 @@ class PanelClimate 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) {
// Refresh the generated view when registries or panels change.
// If the entity registry changed, ask the user if they want to refresh the config
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
@@ -92,46 +63,24 @@ class PanelClimate 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() {
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
await this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
"climate"
);
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 climate configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.climate.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
@@ -148,27 +97,6 @@ class PanelClimate extends LitElement {
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.climate")}</div>
${
!this.hass.user?.is_admin
? nothing
: this._editVisibility
? html`<ha-button
slot="actionItems"
appearance="filled"
size="s"
@click=${this._stopEditVisibility}
>
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
</ha-button>`
: this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.climate.editor.title")}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
@@ -180,7 +108,6 @@ class PanelClimate extends LitElement {
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
></hui-view
></hui-view-container>
`
@@ -190,31 +117,16 @@ class PanelClimate extends LitElement {
`;
}
private async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
private async _setLovelace() {
const viewConfig = await generateLovelaceViewStrategy(
{
strategy: {
...CLIMATE_LOVELACE_VIEW_CONFIG.strategy,
hidden_entities: this._config?.hidden_entities,
edit_visibility: this._editVisibility || undefined,
},
},
CLIMATE_LOVELACE_VIEW_CONFIG,
this.hass
);
if (revision !== this._setLovelaceRevision) {
return;
}
const config = { views: [viewConfig] };
const rawConfig = { views: [CLIMATE_LOVELACE_VIEW_CONFIG] };
if (!force && deepEqual(config, this._lovelace?.config)) {
if (deepEqual(config, this._lovelace?.config)) {
return;
}
@@ -234,60 +146,6 @@ class PanelClimate extends LitElement {
};
}
private _startEditVisibility = () => {
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, hidden } = ev.detail;
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (hidden) {
hiddenEntities.push(entity);
}
this._saveConfig({
...this._config,
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
});
};
private _saveConfig(config: ClimateFrontendSystemData): Promise<void> {
this._config = config;
return queueFrontendSystemDataSave(
this.hass.connection,
"climate",
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error("Failed to save climate configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.climate.editor.save_failed"),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -2,10 +2,9 @@ import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findFilteredEntities,
findEntities,
generateEntityFilter,
type EntityFilter,
type EntityFilterSources,
} from "../../../common/entity/entity_filter";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@@ -13,15 +12,9 @@ import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/sec
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import {
wrapEntityVisibility,
type ComputeEntityCard,
} from "../../lovelace/strategies/helpers/entity-visibility";
export interface ClimateViewStrategyConfig {
type: "climate";
hidden_entities?: string[];
edit_visibility?: boolean;
}
export const climateEntityFilters: EntityFilter[] = [
@@ -49,53 +42,40 @@ export const climateEntityFilters: EntityFilter[] = [
},
];
export const findClimateEntities = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): string[] =>
findFilteredEntities(sources, climateEntityFilters, hiddenEntities);
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])
);
type ClimateAreaSensorKind = "temperature" | "humidity";
export const findClimateAreaSensors = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): Map<string, ClimateAreaSensorKind> => {
const hidden = new Set(hiddenEntities);
const sensors = new Map<string, ClimateAreaSensorKind>();
for (const area of Object.values(sources.areas)) {
const slots = [
[area.temperature_entity_id, "temperature"],
[area.humidity_entity_id, "humidity"],
] as const;
for (const [entityId, kind] of slots) {
if (entityId && entityId in sources.states && !hidden.has(entityId)) {
sensors.set(entityId, kind);
}
}
if (hasAreaSensor) {
return true;
}
return sensors;
const entityIds = Object.keys(hass.states);
return climateEntityFilters.some((filter) =>
entityIds.some(
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
)
);
};
export const hasClimateEntities = (
hass: HomeAssistant,
hiddenEntities?: string[]
): boolean =>
findClimateAreaSensors(hass, hiddenEntities).size > 0 ||
findClimateEntities(hass, hiddenEntities).length > 0;
interface ClimateCardOptions {
entities: string[];
areaSensors: Map<string, ClimateAreaSensorKind>;
computeCard: ComputeEntityCard;
}
const processAreasForClimate = (
areaIds: string[],
hass: HomeAssistant,
{ entities, areaSensors, computeCard }: ClimateCardOptions
entities: string[]
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, true);
for (const areaId of areaIds) {
const area = hass.areas[areaId];
@@ -116,13 +96,25 @@ const processAreasForClimate = (
// Add temperature and humidity sensors with trend graphs for areas
const temperatureEntityId = area.temperature_entity_id;
if (temperatureEntityId && areaSensors.has(temperatureEntityId)) {
areaCards.push(computeCard(temperatureEntityId));
if (temperatureEntityId && hass.states[temperatureEntityId]) {
areaCards.push({
...computeTileCard(temperatureEntityId),
name:
hass.localize("component.sensor.entity_component.temperature.name") ||
"Temperature",
features: [{ type: "trend-graph" }],
});
}
const humidityEntityId = area.humidity_entity_id;
if (humidityEntityId && areaSensors.has(humidityEntityId)) {
areaCards.push(computeCard(humidityEntityId));
if (humidityEntityId && hass.states[humidityEntityId]) {
areaCards.push({
...computeTileCard(humidityEntityId),
name:
hass.localize("component.sensor.entity_component.humidity.name") ||
"Humidity",
features: [{ type: "trend-graph" }],
});
}
// Add other climate entities
@@ -132,7 +124,18 @@ const processAreasForClimate = (
continue;
}
areaCards.push(computeCard(entityId));
const state = hass.states[entityId];
if (
state?.attributes.device_class === "temperature" ||
state?.attributes.device_class === "humidity"
) {
areaCards.push({
...computeTileCard(entityId),
features: [{ type: "trend-graph" }],
});
} else {
areaCards.push(computeTileCard(entityId));
}
}
if (areaCards.length > 0) {
@@ -156,7 +159,7 @@ const processAreasForClimate = (
const processUnassignedEntities = (
hass: HomeAssistant,
{ entities, computeCard }: ClimateCardOptions
entities: string[]
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(
hass.states,
@@ -170,9 +173,10 @@ const processUnassignedEntities = (
);
const unassignedEntities = entities.filter(unassignedFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, true);
for (const entityId of unassignedEntities) {
areaCards.push(computeCard(entityId));
areaCards.push(computeTileCard(entityId));
}
return areaCards;
@@ -181,7 +185,7 @@ const processUnassignedEntities = (
@customElement("climate-view-strategy")
export class ClimateViewStrategy extends ReactiveElement {
static async generate(
config: ClimateViewStrategyConfig,
_config: ClimateViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
@@ -190,40 +194,20 @@ export class ClimateViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const hiddenEntities = new Set(config.hidden_entities);
const editVisibility = Boolean(config.edit_visibility);
const entities = findClimateEntities(
hass,
editVisibility ? undefined : config.hidden_entities
const allEntities = Object.keys(hass.states);
const climateFilters = climateEntityFilters.map((filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
);
const areaSensors = findClimateAreaSensors(
hass,
editVisibility ? undefined : config.hidden_entities
);
const computeTileCard = computeAreaTileCardConfig(hass, true);
const computeClimateCard = (entityId: string): LovelaceCardConfig => {
const tileCard = computeTileCard(entityId);
const areaSensor = areaSensors.get(entityId);
if (!areaSensor) {
return tileCard;
}
return {
...tileCard,
name:
hass.localize(
`component.sensor.entity_component.${areaSensor}.name`
) || undefined,
features: [{ type: "trend-graph" }],
};
};
const cardOptions: ClimateCardOptions = {
entities,
areaSensors,
computeCard: editVisibility
? wrapEntityVisibility(hiddenEntities, computeClimateCard)
: computeClimateCard,
};
const entities = findEntities(allEntities, climateFilters);
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -249,7 +233,7 @@ export class ClimateViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForClimate(areaIds, hass, cardOptions);
const areaCards = processAreasForClimate(areaIds, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -273,11 +257,7 @@ export class ClimateViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForClimate(
hierarchy.areas,
hass,
cardOptions
);
const areaCards = processAreasForClimate(hierarchy.areas, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -286,7 +266,7 @@ export class ClimateViewStrategy extends ReactiveElement {
}
// Process unassigned entities
const unassignedCards = processUnassignedEntities(hass, cardOptions);
const unassignedCards = processUnassignedEntities(hass, entities);
if (unassignedCards.length > 0) {
const section: LovelaceSectionRawConfig = {
@@ -857,6 +857,8 @@ class HaConfigAreaPage extends LitElement {
},
this.hass.auth.data.hassUrl
)}
width="24"
height="24"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>`;
@@ -1061,7 +1063,7 @@ class HaConfigAreaPage extends LitElement {
justify-content: space-around;
}
img {
.img-container img {
border-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
@@ -1115,11 +1117,6 @@ class HaConfigAreaPage extends LitElement {
ha-list-item-button ha-icon-next {
color: var(--secondary-text-color);
}
img[slot="start"] {
width: 24px;
height: 24px;
object-fit: contain;
}
ha-card > *:first-child {
margin-top: -16px;
}
@@ -128,6 +128,8 @@ import "./types/ha-automation-action-wait_for_trigger";
import "./types/ha-automation-action-wait_template";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
import { renderRowErrorIcon } from "../automation-row-error-icon";
export interface ActionElement extends LitElement {
action: Action;
@@ -182,6 +184,8 @@ export default class HaAutomationActionRow extends LitElement {
@property({ type: Boolean }) public root = false;
@property({ attribute: false }) public index!: number;
@property({ type: Boolean }) public first?: boolean;
@property({ type: Boolean }) public last?: boolean;
@@ -194,6 +198,11 @@ export default class HaAutomationActionRow extends LitElement {
@property({ type: Boolean, attribute: "sort-selected" })
public sortSelected = false;
protected readonly pathController = new AutomationPathController(this, {
segment: () => [this.index],
row: () => (this.optionsInSidebar ? this : undefined),
});
@storage({
key: "automationClipboard",
state: false,
@@ -490,6 +499,11 @@ export default class HaAutomationActionRow extends LitElement {
${this._runResult?.title}
</ha-automation-row-event-chip>
${renderRowErrorIcon(
this.pathController.errors,
this.pathController.path,
this.hass.localize("ui.panel.config.automation.editor.errors.label")
)}
<slot name="icons" slot="icons"></slot>
<ha-dropdown
@@ -790,6 +804,7 @@ export default class HaAutomationActionRow extends LitElement {
...ACTION_COMBINED_BLOCKS,
].includes(blockType!)}
.sortSelected=${this.sortSelected}
.error=${this.pathController.errors.length > 0}
.dim=${this._running}
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
@@ -43,6 +43,7 @@ export class HaChooseAction extends LitElement implements ActionElement {
return html`
<ha-automation-option
path-key="choose"
.options=${options}
.disabled=${this.disabled}
@value-changed=${this._optionsChanged}
@@ -27,9 +27,18 @@ import {
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
import {
deviceAutomationErrorText,
deviceErrorText,
} from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
@customElement("ha-automation-action-device_id")
export class HaDeviceAction extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["device_id", "domain"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@@ -131,12 +140,20 @@ export class HaDeviceAction extends LitElement {
}
protected render() {
const deviceError = this._fieldErrors.fieldError("device_id");
const automationError = this._fieldErrors.fieldError("domain");
const deviceId = this._deviceId || this.action.device_id;
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.invalid=${!!deviceError}
.errorMessage=${
deviceError
? deviceErrorText(this.hass.localize, deviceError)
: undefined
}
.disabled=${this.disabled}
@value-changed=${this._devicePicked}
label=${this.hass.localize(
@@ -144,6 +161,16 @@ export class HaDeviceAction extends LitElement {
)}
></ha-device-picker>
<ha-device-action-picker
.invalid=${!!automationError}
.errorMessage=${
automationError
? deviceAutomationErrorText(
this.hass.localize,
"actions",
automationError
)
: undefined
}
.value=${this.action}
.deviceId=${deviceId}
.disabled=${this.disabled}
@@ -46,6 +46,7 @@ export class HaIfAction extends LitElement implements ActionElement {
)}:
</h4>
<ha-automation-condition
path-key="if"
.conditions=${action.if ?? []}
.disabled=${this.disabled}
@value-changed=${this._ifChanged}
@@ -60,6 +61,7 @@ export class HaIfAction extends LitElement implements ActionElement {
)}:
</h4>
<ha-automation-action
path-key="then"
.actions=${action.then ?? []}
.disabled=${this.disabled}
@value-changed=${this._thenChanged}
@@ -73,6 +75,7 @@ export class HaIfAction extends LitElement implements ActionElement {
)}:
</h4>
<ha-automation-action
path-key="else"
.actions=${action.else || []}
.disabled=${this.disabled}
@value-changed=${this._elseChanged}
@@ -35,6 +35,7 @@ export class HaParallelAction extends LitElement implements ActionElement {
return html`
<ha-automation-action
path-key="parallel"
.actions=${action.parallel}
.narrow=${this.narrow}
.disabled=${this.disabled}
@@ -8,6 +8,7 @@ import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import "../ha-automation-action";
import type { ActionElement } from "../ha-automation-action-row";
import { AutomationPathController } from "../../automation-path";
import { isTemplate } from "../../../../../common/string/has-template";
import "../../../../../components/ha-form/ha-form";
@@ -40,6 +41,11 @@ export class HaRepeatAction extends LitElement implements ActionElement {
@property({ type: Boolean, attribute: "indent" }) public indent = false;
// The form edits the repeat options, so its fields are keyed below "repeat"
protected readonly pathController = new AutomationPathController(this, {
segment: () => ["repeat"],
});
@query("ha-form")
private _formElement?: HaForm;
@@ -35,6 +35,7 @@ export class HaSequenceAction extends LitElement implements ActionElement {
return html`
<ha-automation-action
path-key="sequence"
.actions=${action.sequence ?? []}
.narrow=${this.narrow}
.disabled=${this.disabled}
@@ -12,6 +12,8 @@ import type { ServiceAction } from "../../../../../data/script";
import { serviceActionStruct } from "../../../../../data/script";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
import { targetErrorText } from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type { ActionElement } from "../ha-automation-action-row";
@customElement("ha-automation-action-service")
@@ -28,6 +30,10 @@ export class HaServiceAction extends LitElement implements ActionElement {
@state() private _responseChecked = false;
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["target", "data"],
});
public static get defaultConfig(): ServiceAction {
return { action: "", data: {} };
}
@@ -80,6 +86,8 @@ export class HaServiceAction extends LitElement implements ActionElement {
? this._action.action.split(".", 2)
: [undefined, undefined];
const targetError = this._fieldErrors.fieldError("target");
const optionalResponse =
domain && service
? !!this.hass.services[domain]?.[service]?.response?.optional
@@ -92,6 +100,13 @@ export class HaServiceAction extends LitElement implements ActionElement {
.value=${this._action}
.disabled=${this.disabled}
.hidePicker=${!!this._action.metadata}
.targetError=${
targetError
? targetErrorText(this.hass.localize, targetError)
: undefined
}
.dataErrors=${this._fieldErrors.nestedFieldErrors("data")}
.dataError=${this._fieldErrors.ownFieldError("data")?.message}
@value-changed=${this._actionChanged}
></ha-service-control>
${
@@ -90,6 +90,7 @@ export class HaWaitForTriggerAction
${
this.indent || (!this.inSidebar && !this.indent)
? html`<ha-automation-trigger
path-key="wait_for_trigger"
class=${!this.inSidebar && !this.indent ? "expansion-panel" : ""}
.triggers=${ensureArray(this.action.wait_for_trigger)}
.hass=${this.hass}
@@ -7,6 +7,7 @@ import type { WaitAction } from "../../../../../data/script";
import type { HomeAssistant } from "../../../../../types";
import type { ActionElement } from "../ha-automation-action-row";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type {
HaFormSchema,
SchemaUnion,
@@ -15,6 +16,10 @@ import type { TimeoutType } from "../../types";
@customElement("ha-automation-action-wait_template")
export class HaWaitAction extends LitElement implements ActionElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["wait_template", "timeout", "continue_on_timeout"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public action!: WaitAction;
@@ -65,6 +70,7 @@ export class HaWaitAction extends LitElement implements ActionElement {
: "duration";
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${this.action}
.schema=${this._schema(timeoutType, timeout !== undefined)}
@@ -0,0 +1,350 @@
import { createContext } from "@lit/context";
import { ensureArray } from "../../../common/array/ensure-array";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
formatPath,
pathEquals,
pathStartsWith,
type ConfigPath,
} from "../../../common/util/config-path";
import type { ConfigValidationError } from "../../../data/config";
import type {
AutomationPathRow,
AutomationRowRegistry,
} from "./automation-path";
/** The validation errors of the config being edited, paths from its root. */
export const automationErrorsContext =
createContext<ConfigValidationError[]>("automation-errors");
export const NO_ERRORS: ConfigValidationError[] = [];
/**
* The row an error belongs to: the deepest rendered row on its path. Rows
* above it only contain the error, they are expanded to show it.
*/
export const errorOwner = (
registry: AutomationRowRegistry,
error: ConfigValidationError
): AutomationPathRow | undefined => {
const rows = registry.rowsAlong(error.path);
return rows[rows.length - 1];
};
export const ownedErrors = (
registry: AutomationRowRegistry,
errors: ConfigValidationError[],
row: AutomationPathRow
): ConfigValidationError[] => {
const owned = errors.filter((error) => errorOwner(registry, error) === row);
return owned.length ? owned : NO_ERRORS;
};
export const fieldErrors = (
errors: ConfigValidationError[],
path: ConfigPath
): Record<string, string> => {
const fields: Record<string, string> = {};
errors.forEach((error) => {
const field = errorField(error, path);
if (field !== undefined && !(field in fields)) {
fields[field] = error.message;
}
});
return fields;
};
/** The field of the config at `path` an error is about, if any. */
export const errorField = (
error: ConfigValidationError,
path: ConfigPath
): string | undefined =>
error.path.length > path.length && pathStartsWith(error.path, path)
? String(error.path[path.length])
: undefined;
export const errorText = (
error: ConfigValidationError,
path: ConfigPath = []
): string => {
const relative = pathStartsWith(error.path, path)
? error.path.slice(path.length)
: error.path;
return relative.length
? `${formatPath(relative)}: ${error.message}`
: error.message;
};
export const errorsEqual = (
a: ConfigValidationError[],
b: ConfigValidationError[]
): boolean =>
a.length === b.length &&
a.every(
(error, index) =>
error.message === b[index].message &&
error.code === b[index].code &&
pathEquals(error.path, b[index].path)
);
const TRIGGER_LIST_KEYS = ["triggers", "wait_for_trigger"];
const isTriggerSublist = (value: unknown): value is { triggers: unknown } =>
typeof value === "object" &&
value !== null &&
Object.keys(value).length === 1 &&
"triggers" in value;
// Core validates triggers after flattening `triggers:` sublists one level
// deep, so its trigger indexes count the triggers of the flat list.
const flatTriggerPaths = (triggers: unknown[]): ConfigPath[] =>
triggers.flatMap((trigger, index) =>
isTriggerSublist(trigger)
? (ensureArray(trigger.triggers) ?? []).map((_, subIndex) => [
index,
"triggers",
subIndex,
])
: [[index]]
);
const isConfigObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const childAt = (value: unknown, segment: string | number): unknown =>
typeof value === "object" && value !== null
? (value as Record<string | number, unknown>)[segment]
: undefined;
const valueAt = (config: unknown, path: ConfigPath): unknown =>
path.reduce<unknown>(childAt, config);
/** Maps an error path of core's validated config onto the edited config. */
export const configErrorPath = (
config: unknown,
path: ConfigPath
): ConfigPath => {
const result: ConfigPath = [];
let value = config;
for (let index = 0; index < path.length; index++) {
const segment = path[index];
const parentKey = result[result.length - 1];
// Core wraps each action of the `parallel` shorthand in a sequence
if (
segment === "sequence" &&
path[index + 1] === 0 &&
result[result.length - 2] === "parallel" &&
isConfigObject(value) &&
!("sequence" in value)
) {
index++;
continue;
}
const mapped =
typeof segment === "number" &&
Array.isArray(value) &&
TRIGGER_LIST_KEYS.includes(parentKey as string)
? flatTriggerPaths(value)[segment]
: undefined;
(mapped ?? [segment]).forEach((part) => {
result.push(part);
value = childAt(value, part);
});
}
return result;
};
export const mapErrorsToConfig = (
config: unknown,
errors: ConfigValidationError[]
): ConfigValidationError[] =>
errors.map((error) => ({
...error,
path: configErrorPath(config, error.path),
}));
// Rows sit at list items that are objects, not at entries of value lists
// like `entity_id`
const errorItemPath = (config: unknown, path: ConfigPath): ConfigPath => {
let value = config;
let end = 0;
path.forEach((segment, index) => {
value = childAt(value, segment);
if (typeof segment === "number" && isConfigObject(value)) {
end = index + 1;
}
});
return path.slice(0, end);
};
/** A config item with an error, to recognize it after edits. */
export interface KnownErrorItem {
path: ConfigPath;
item?: object;
content?: string;
}
const knownItemAt = (config: unknown, path: ConfigPath): KnownErrorItem => {
const item = valueAt(config, path);
return isConfigObject(item)
? { path, item, content: JSON.stringify(item) }
: { path };
};
export const knownErrorItems = (
config: unknown,
errors: ConfigValidationError[]
): KnownErrorItem[] => {
const known: KnownErrorItem[] = [];
errors.forEach((error) => {
const path = errorItemPath(config, error.path);
if (!known.some((entry) => pathEquals(entry.path, path))) {
known.push(knownItemAt(config, path));
}
});
return known;
};
interface ConfigItems {
paths: Map<object, ConfigPath>;
contents: Map<string, ConfigPath[]>;
}
// The list entries that are objects, the items rows show
const configItems = (config: unknown): ConfigItems => {
const items: ConfigItems = { paths: new Map(), contents: new Map() };
const walk = (value: unknown, path: ConfigPath) => {
if (typeof value !== "object" || value === null) {
return;
}
if (typeof path[path.length - 1] === "number" && isConfigObject(value)) {
items.paths.set(value, path);
const content = JSON.stringify(value);
items.contents.set(content, [
...(items.contents.get(content) ?? []),
path,
]);
}
Object.entries(value).forEach(([key, child]) => {
walk(child, [...path, Array.isArray(value) ? Number(key) : key]);
});
};
walk(config, []);
return items;
};
const followItem = (
entry: KnownErrorItem,
previous: ConfigItems,
current: ConfigItems,
config: unknown
): KnownErrorItem | undefined => {
// Unchanged or moved
const path = entry.item && current.paths.get(entry.item);
if (path) {
return { ...entry, path };
}
// Moved after the YAML was parsed again
const samePaths = entry.content
? current.contents.get(entry.content)
: undefined;
if (samePaths?.length === 1) {
return knownItemAt(config, samePaths[0]);
}
// Identical items, e.g. two copies of a row, keep their place
if (samePaths?.some((other) => pathEquals(other, entry.path))) {
return knownItemAt(config, entry.path);
}
const now = valueAt(config, entry.path);
if (!isConfigObject(now)) {
return undefined;
}
// An item that was already there took the place, e.g. after a delete
const beforePaths = previous.paths.has(now)
? [previous.paths.get(now)!]
: (previous.contents.get(JSON.stringify(now)) ?? []);
if (
beforePaths.length &&
!beforePaths.some((other) => pathEquals(other, entry.path))
) {
return undefined;
}
// Edited in place
return knownItemAt(config, entry.path);
};
/** Follows known items from `previous` to `config` through moves and edits. */
export const followKnownErrorItems = (
known: KnownErrorItem[],
previous: unknown,
config: unknown
): KnownErrorItem[] => {
const previousItems = configItems(previous);
const currentItems = configItems(config);
return known
.map((entry) => followItem(entry, previousItems, currentItems, config))
.filter((entry): entry is KnownErrorItem => entry !== undefined);
};
/**
* Moves errors of `previous` to where their items are in `config`. Errors of
* items that are gone are dropped.
*/
export const followErrors = (
errors: ConfigValidationError[],
previous: unknown,
config: unknown
): ConfigValidationError[] => {
const previousItems = configItems(previous);
const currentItems = configItems(config);
return errors.flatMap((error) => {
const path = errorItemPath(previous, error.path);
const moved = followItem(
knownItemAt(previous, path),
previousItems,
currentItems,
config
);
return moved
? [{ ...error, path: [...moved.path, ...error.path.slice(path.length)] }]
: [];
});
};
/** The errors on items that already had one. */
export const errorsOnKnownItems = (
known: KnownErrorItem[],
config: unknown,
errors: ConfigValidationError[]
): ConfigValidationError[] =>
errors.filter((error) => {
const path = errorItemPath(config, error.path);
return known.some((entry) => pathEquals(entry.path, path));
});
export const targetErrorText = (
localize: LocalizeFunc,
error: ConfigValidationError
): string =>
localize(
error.code === "required"
? "ui.panel.config.automation.editor.errors.target_required"
: "ui.panel.config.automation.editor.errors.target_invalid"
);
export const deviceErrorText = (
localize: LocalizeFunc,
error: ConfigValidationError
): string =>
error.code === "required"
? localize("ui.panel.config.automation.editor.errors.device_required")
: error.message;
export const deviceAutomationErrorText = (
localize: LocalizeFunc,
kind: "triggers" | "conditions" | "actions",
error: ConfigValidationError
): string =>
error.code === "required"
? localize(`ui.panel.config.devices.automation.${kind}.required`)
: error.message;
@@ -0,0 +1,186 @@
import { createContext } from "@lit/context";
import {
consumeContext,
ContextController,
} from "../../../common/decorators/consume";
import { pathEquals, type ConfigPath } from "../../../common/util/config-path";
import type { ConfigValidationError } from "../../../data/config";
import {
automationErrorsContext,
errorField,
errorsEqual,
fieldErrors,
NO_ERRORS,
ownedErrors,
} from "./automation-errors";
import {
automationPathContext,
automationRowRegistryContext,
type AutomationRowRegistry,
} from "./automation-path";
const fieldsEqual = (a: Record<string, string>, b: Record<string, string>) => {
const keys = Object.keys(a);
return (
keys.length === Object.keys(b).length &&
keys.every((key) => a[key] === b[key])
);
};
/**
* @summary The fields whose errors an editor shows next to the field, so
* the sidebar doesn't list them again.
*/
export class InlineErrorFields {
private _fields = new Map<object, string[]>();
constructor(private readonly _onChange: () => void) {}
public set(owner: object, fields: string[]) {
this._fields.set(owner, fields);
this._onChange();
}
public delete(owner: object) {
if (this._fields.delete(owner)) {
this._onChange();
}
}
public has(field: string): boolean {
return [...this._fields.values()].some((fields) => fields.includes(field));
}
}
export const automationInlineErrorFieldsContext =
createContext<InlineErrorFields>("automation-inline-error-fields");
interface AutomationFieldErrorsOptions {
/** Fields the host shows the error of, see `InlineErrorFields`. */
inlineFields?: string[];
}
/**
* @summary Validation errors of the config at the host's path.
*
* Consumes the errors and the path of the closest ancestor and updates the
* host when the errors it exposes change.
*/
export class AutomationFieldErrorsController extends ContextController {
public fields: Record<string, string> = {};
/** Errors that belong to the row at exactly the path, see `errorOwner`. */
public rowErrors: ConfigValidationError[] = NO_ERRORS;
public path: ConfigPath = [];
@consumeContext({ context: automationErrorsContext, subscribe: true })
private _errors: ConfigValidationError[] = NO_ERRORS;
@consumeContext({ context: automationPathContext, subscribe: true })
private _path: ConfigPath = [];
@consumeContext({ context: automationRowRegistryContext })
private _registry?: AutomationRowRegistry;
@consumeContext({ context: automationInlineErrorFieldsContext })
private _inlineErrorFields?: InlineErrorFields;
private readonly _inlineFields?: string[];
private _claimedIn?: InlineErrorFields;
constructor(
host: ConstructorParameters<typeof ContextController>[0],
options: AutomationFieldErrorsOptions = {}
) {
super(host);
this._inlineFields = options.inlineFields;
}
private _nestedFieldErrors = new Map<string, Record<string, string>>();
/** First error message of each key inside a field, keyed by the key. */
public nestedFieldErrors(field: string): Record<string, string> {
const errors = fieldErrors(this._errors, [...this._path, field]);
const previous = this._nestedFieldErrors.get(field);
// The same object while nothing changed, so the host's child doesn't update
if (previous && fieldsEqual(previous, errors)) {
return previous;
}
this._nestedFieldErrors.set(field, errors);
return errors;
}
/** The error of a field as a whole, not of something inside it. */
public ownFieldError(field: string): ConfigValidationError | undefined {
return this._errors.find((error) =>
pathEquals(error.path, [...this._path, field])
);
}
/** The first error of a field of the config at the path. */
public fieldError(field: string): ConfigValidationError | undefined {
return this._errors.find(
(error) => errorField(error, this._path) === field
);
}
private _unsubscribeRegistry?: () => void;
public hostConnected() {
this._unsubscribeRegistry ??= this._registry?.subscribe(() => this._sync());
this._claimInlineFields();
this._sync();
}
public hostDisconnected() {
this._unsubscribeRegistry?.();
this._unsubscribeRegistry = undefined;
this._claimedIn?.delete(this);
this._claimedIn = undefined;
}
protected contextUpdated() {
this._claimInlineFields();
this._sync();
}
private _claimInlineFields() {
if (
!this._inlineFields ||
!this._inlineErrorFields ||
this._claimedIn === this._inlineErrorFields
) {
return;
}
this._claimedIn?.delete(this);
this._inlineErrorFields.set(this, this._inlineFields);
this._claimedIn = this._inlineErrorFields;
}
private _sync() {
const fields = this._errors.length
? fieldErrors(this._errors, this._path)
: {};
const row =
this._errors.length && this._path.length
? this._registry?.rowAt(this._path)
: undefined;
const rowErrors =
row && this._registry
? ownedErrors(this._registry, this._errors, row)
: NO_ERRORS;
if (
this.path === this._path &&
fieldsEqual(fields, this.fields) &&
errorsEqual(rowErrors, this.rowErrors)
) {
return;
}
this.path = this._path;
this.fields = fields;
this.rowErrors = rowErrors;
this.host.requestUpdate();
}
}
@@ -0,0 +1,239 @@
import { ContextProvider, createContext } from "@lit/context";
import type { ReactiveElement } from "lit";
import {
consumeContext,
ContextController,
} from "../../../common/decorators/consume";
import {
pathEquals,
stringifyPath,
type ConfigPath,
} from "../../../common/util/config-path";
import type { ConfigValidationError } from "../../../data/config";
import {
automationErrorsContext,
errorsEqual,
NO_ERRORS,
ownedErrors,
} from "./automation-errors";
export const automationPathContext =
createContext<ConfigPath>("automation-path");
export interface AutomationPathRow extends HTMLElement {
readonly selected: boolean;
openSidebar(): void;
expand(): void;
}
const owner = (rows?: AutomationPathRow[]) => rows?.[rows.length - 1];
/**
* Rows that open in the sidebar, by path. Lets the editor find the row a
* config path belongs to, e.g. to show it or point at an error in it.
*/
export class AutomationRowRegistry {
// Rows claiming each path, the latest one owns it. While a row is dragged
// over another list it claims the path of a row there, which takes the path
// back once the dragged row leaves.
private _rows = new Map<
string,
{ path: ConfigPath; rows: AutomationPathRow[] }
>();
private _listeners = new Set<() => void>();
private _notifyScheduled = false;
public register(path: ConfigPath, row: AutomationPathRow) {
const key = stringifyPath(path);
const entry = this._rows.get(key);
if (!entry) {
this._rows.set(key, { path, rows: [row] });
} else {
entry.rows = [...entry.rows.filter((claimed) => claimed !== row), row];
}
this._changed();
}
public unregister(path: ConfigPath, row: AutomationPathRow) {
const key = stringifyPath(path);
const entry = this._rows.get(key);
if (!entry) {
return;
}
entry.rows = entry.rows.filter((claimed) => claimed !== row);
if (!entry.rows.length) {
this._rows.delete(key);
}
this._changed();
}
/**
* Calls `listener` after rows were registered or unregistered, once for all
* changes made in the same task. Returns the function to unsubscribe.
*/
public subscribe(listener: () => void): () => void {
this._listeners.add(listener);
return () => {
this._listeners.delete(listener);
};
}
private _changed() {
if (this._notifyScheduled || !this._listeners.size) {
return;
}
this._notifyScheduled = true;
queueMicrotask(() => {
this._notifyScheduled = false;
this._listeners.forEach((listener) => listener());
});
}
public rowAt(path: ConfigPath): AutomationPathRow | undefined {
return owner(this._rows.get(stringifyPath(path))?.rows);
}
public pathOf(row: AutomationPathRow): ConfigPath | undefined {
return [...this._rows.values()].find((entry) => owner(entry.rows) === row)
?.path;
}
public selectedPath(): ConfigPath | undefined {
return [...this._rows.values()].find((entry) => owner(entry.rows)?.selected)
?.path;
}
/** The rows containing `path`, outermost first. */
public rowsAlong(path: ConfigPath): AutomationPathRow[] {
return path
.map((_segment, index) =>
owner(this._rows.get(stringifyPath(path.slice(0, index + 1)))?.rows)
)
.filter((row): row is AutomationPathRow => row !== undefined);
}
}
export const automationRowRegistryContext =
createContext<AutomationRowRegistry>("automation-row-registry");
interface AutomationPathControllerOptions {
/** Path segments this element adds to the path of its ancestor. */
segment: () => ConfigPath;
/** The host while it is a row that opens in the sidebar. */
row?: () => AutomationPathRow | undefined;
}
/**
* Consumes the path of the closest ancestor and provides it extended by the
* host's own segment to its descendants. For a row, also finds the validation
* errors the row owns and updates the host when they change.
*/
export class AutomationPathController extends ContextController {
public path: ConfigPath = [];
public errors: ConfigValidationError[] = NO_ERRORS;
@consumeContext({ context: automationErrorsContext, subscribe: true })
private _configErrors: ConfigValidationError[] = NO_ERRORS;
private _unsubscribeRegistry?: () => void;
@consumeContext({ context: automationPathContext, subscribe: true })
private _parentPath: ConfigPath = [];
@consumeContext({ context: automationRowRegistryContext })
private _registry?: AutomationRowRegistry;
private _registeredPath?: ConfigPath;
private _registeredRow?: AutomationPathRow;
private readonly _provider: ContextProvider<typeof automationPathContext>;
constructor(
host: ReactiveElement,
private readonly _options: AutomationPathControllerOptions
) {
super(host);
this._provider = new ContextProvider(host, {
context: automationPathContext,
initialValue: this.path,
});
}
// The path is not rendered, so a changed ancestor path is passed on without
// updating the host. Only the host's own segment changes go through an update.
protected contextUpdated() {
this._sync();
}
public hostUpdate() {
this._sync();
}
public hostConnected() {
// A row moved by a drag reconnects without an update
this._sync();
if (this._options.row && this._registry && !this._unsubscribeRegistry) {
// Another row on the path of an error can take it over or give it back
this._unsubscribeRegistry = this._registry.subscribe(() =>
this._syncErrors()
);
}
}
private _sync() {
const path = [...this._parentPath, ...this._options.segment()];
if (!pathEquals(path, this.path)) {
this.path = path;
this._provider.setValue(path);
}
this._syncRegistration();
this._syncErrors();
}
public hostDisconnected() {
this._unsubscribeRegistry?.();
this._unsubscribeRegistry = undefined;
this._unregister();
}
private _syncErrors() {
const errors =
this._registeredRow && this._registry && this._configErrors.length
? ownedErrors(this._registry, this._configErrors, this._registeredRow)
: NO_ERRORS;
if (!errorsEqual(errors, this.errors)) {
this.errors = errors;
this.host.requestUpdate();
}
}
private _syncRegistration() {
if (!this._registry || !this._options.row) {
return;
}
const row = this.host.isConnected ? this._options.row() : undefined;
if (
this._registeredPath &&
(!row || !pathEquals(this._registeredPath, this.path))
) {
this._unregister();
}
if (row && !this._registeredPath) {
this._registry.register(this.path, row);
this._registeredPath = this.path;
this._registeredRow = row;
}
}
private _unregister() {
if (this._registeredPath && this._registeredRow) {
this._registry?.unregister(this._registeredPath, this._registeredRow);
}
this._registeredPath = undefined;
this._registeredRow = undefined;
}
}
@@ -0,0 +1,27 @@
import { mdiAlertCircle } from "@mdi/js";
import { html, nothing } from "lit";
import type { ConfigPath } from "../../../common/util/config-path";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import type { ConfigValidationError } from "../../../data/config";
import { errorText } from "./automation-errors";
export const renderRowErrorIcon = (
errors: ConfigValidationError[],
path: ConfigPath,
label: string
) =>
errors.length
? html`<ha-svg-icon
id="error-icon"
slot="icons"
tabindex="0"
class="error-indicator"
.path=${mdiAlertCircle}
role="img"
aria-label=${label}
></ha-svg-icon>
<ha-tooltip for="error-icon" slot="icons">
${errors.map((error) => html`<p>${errorText(error, path)}</p>`)}
</ha-tooltip>`
: nothing;
@@ -96,6 +96,8 @@ import "./types/ha-automation-condition-trigger";
import "./types/ha-automation-condition-zone";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
import { renderRowErrorIcon } from "../automation-row-error-icon";
export interface ConditionElement extends LitElement {
condition: Condition;
@@ -113,6 +115,8 @@ export default class HaAutomationConditionRow extends LitElement {
@property({ type: Boolean }) public root = false;
@property({ attribute: false }) public index!: number;
@property({ type: Boolean }) public first?: boolean;
@property({ type: Boolean }) public last?: boolean;
@@ -124,6 +128,11 @@ export default class HaAutomationConditionRow extends LitElement {
@property({ type: Boolean, attribute: "sort-selected" })
public sortSelected = false;
protected readonly pathController = new AutomationPathController(this, {
segment: () => [this.index],
row: () => (this.optionsInSidebar ? this : undefined),
});
@state() private _collapsed = true;
@state() private _isNew = false;
@@ -254,6 +263,11 @@ export default class HaAutomationConditionRow extends LitElement {
)}
</ha-automation-row-event-chip>
${renderRowErrorIcon(
this.pathController.errors,
this.pathController.path,
this.hass.localize("ui.panel.config.automation.editor.errors.label")
)}
<slot name="icons" slot="icons"></slot>
<ha-dropdown
@@ -521,6 +535,7 @@ export default class HaAutomationConditionRow extends LitElement {
this.condition.condition
)}
.sortSelected=${this.sortSelected}
.error=${this.pathController.errors.length > 0}
.dim=${this._testing}
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
@@ -27,9 +27,18 @@ import {
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
import {
deviceAutomationErrorText,
deviceErrorText,
} from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
@customElement("ha-automation-condition-device")
export class HaDeviceCondition extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["device_id", "domain"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Object }) public condition!: DeviceCondition;
@@ -132,12 +141,20 @@ export class HaDeviceCondition extends LitElement {
}
protected render() {
const deviceError = this._fieldErrors.fieldError("device_id");
const automationError = this._fieldErrors.fieldError("domain");
const deviceId = this._deviceId || this.condition.device_id;
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.invalid=${!!deviceError}
.errorMessage=${
deviceError
? deviceErrorText(this.hass.localize, deviceError)
: undefined
}
@value-changed=${this._devicePicked}
.disabled=${this.disabled}
.label=${this.hass.localize(
@@ -145,6 +162,16 @@ export class HaDeviceCondition extends LitElement {
)}
></ha-device-picker>
<ha-device-condition-picker
.invalid=${!!automationError}
.errorMessage=${
automationError
? deviceAutomationErrorText(
this.hass.localize,
"conditions",
automationError
)
: undefined
}
.value=${this.condition}
.deviceId=${deviceId}
@value-changed=${this._deviceConditionPicked}
@@ -29,6 +29,7 @@ export abstract class HaLogicalCondition
protected render() {
return html`
<ha-automation-condition
path-key="conditions"
.conditions=${this.condition.conditions || []}
@value-changed=${this._valueChanged}
.hass=${this.hass}
@@ -34,6 +34,9 @@ import {
} from "../../../../../data/target";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
import "../../../../../components/ha-input-helper-text";
import { targetErrorText } from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import { targetExpandGroupContext } from "../../../../../components/target-picker/target-expand-group-context";
// Mirrors `MAX_HISTORY_PRIMING_LOOKBACK` in homeassistant/helpers/condition.py:
@@ -53,6 +56,10 @@ export class HaPlatformCondition extends LitElement {
@provide({ context: targetExpandGroupContext })
protected _targetExpandGroup = false;
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["target"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: PlatformCondition;
@@ -234,6 +241,7 @@ export class HaPlatformCondition extends LitElement {
></ha-selector>`
: nothing
}
${this._renderTargetError()}
${
shouldRenderDataYaml
? html`<ha-yaml-editor
@@ -258,6 +266,15 @@ export class HaPlatformCondition extends LitElement {
`;
}
private _renderTargetError() {
const error = this._fieldErrors.fieldError("target");
return error
? html`<ha-input-helper-text class="target-error">
${targetErrorText(this.hass.localize, error)}
</ha-input-helper-text>`
: nothing;
}
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector["target"] | null | undefined) =>
targetSelector ? { target: { ...targetSelector } } : { target: {} }
@@ -680,6 +697,10 @@ export class HaPlatformCondition extends LitElement {
1px solid var(--divider-color)
);
}
.target-error {
color: var(--ha-color-on-danger-quiet);
padding-bottom: var(--ha-space-2);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
@@ -17,6 +17,7 @@ import { ensureArray } from "../../../../../common/array/ensure-array";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { StateCondition } from "../../../../../data/automation";
import { STATE_CONDITION_HIDDEN_ATTRIBUTES } from "../../../../../data/entity/entity_attributes";
@@ -71,6 +72,10 @@ const SCHEMA = memoizeOne(
@customElement("ha-automation-condition-state")
export class HaStateCondition extends LitElement implements ConditionElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["entity_id", "attribute", "state", "for"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: StateCondition;
@@ -118,6 +123,7 @@ export class HaStateCondition extends LitElement implements ConditionElement {
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA(hasAttribute)}
@@ -56,7 +56,6 @@ import {
triggerAutomationActions,
} from "../../../data/automation";
import { substituteBlueprint } from "../../../data/blueprint";
import { validateConfig } from "../../../data/config";
import { UNAVAILABLE } from "../../../data/entity/entity";
import {
type EntityRegistryEntry,
@@ -71,7 +70,7 @@ import "../../../layouts/hass-subpage";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { haStyle } from "../../../resources/styles";
import type { Entries, ValueChangedEvent } from "../../../types";
import type { ValueChangedEvent } from "../../../types";
import { isMac } from "../../../util/is_mac";
import { showEditorToast } from "./editor-toast";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
@@ -159,7 +158,14 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
const { id, ...rest } = normalizeAutomationConfig(config, report);
return { id, ...rest };
},
checkValidation: () => this._checkValidation(),
validationSections: (config) =>
"use_blueprint" in config
? {}
: {
triggers: config.triggers,
conditions: config.conditions,
actions: config.actions,
},
};
private _undoRedoController = new UndoRedoController<AutomationConfig>(this, {
@@ -501,16 +507,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
>
${
this.errors
? html`<ha-alert
alert-type="error"
slot="alerts"
>
${this.errors}
</ha-alert>`
: nothing
}
${this.renderConfigErrorsAlert("alerts")}
</blueprint-automation-editor>
`
: html`
@@ -522,6 +519,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
.config=${this.config}
.disabled=${this.readOnly}
.saving=${this.saving}
.configErrors=${this.configErrors}
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
@editor-save=${this._handleSaveAutomation}
@@ -530,26 +528,17 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
<div class="alert-wrapper" slot="alerts">
${this._renderDeprecatedMigratedAlert()}
${
this.errors || stateObj?.state === UNAVAILABLE
stateObj?.state === UNAVAILABLE
? html`<ha-alert
alert-type="error"
.title=${
stateObj?.state === UNAVAILABLE
? this.hass.localize(
"ui.panel.config.automation.editor.unavailable"
)
: undefined
}
.title=${this.hass.localize(
"ui.panel.config.automation.editor.unavailable"
)}
>
${this.errors || this.validationErrors}
${
stateObj?.state === UNAVAILABLE
? html`<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>`
: nothing
}
<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>
</ha-alert>`
: nothing
}
@@ -628,6 +617,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
`
: this.mode === "yaml"
? html`${this._renderDeprecatedMigratedAlert()}
${this.renderConfigErrorsAlert()}
${
stateObj?.state === "off"
? html`
@@ -747,7 +737,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this._checkValidation();
this.checkValidation();
});
this.currentEntityId = this.entityId;
this.readOnly = true;
@@ -775,32 +765,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this.currentEntityId = automation?.entity_id;
}
private async _checkValidation() {
this.validationErrors = undefined;
if (!this.currentEntityId || !this.config) {
return;
}
const stateObj = this.hass.states[this.currentEntityId];
if (stateObj?.state !== UNAVAILABLE) {
return;
}
const validation = await validateConfig(this.hass, {
triggers: this.config.triggers,
conditions: this.config.conditions,
actions: this.config.actions,
});
this.validationErrors = (
Object.entries(validation) as Entries<typeof validation>
).map(([key, value]) =>
value.valid
? ""
: html`${this.hass.localize(
`ui.panel.config.automation.editor.${key}.name`
)}:
${value.error}<br />`
);
}
private _valueChanged(ev: ValueChangedEvent<AutomationConfig>) {
ev.stopPropagation();
const config = ev.detail.value;
@@ -830,7 +794,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.errors = undefined;
}
private _showInfo() {
@@ -920,7 +883,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config!,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.errors = undefined;
}
protected async confirmUnsavedChanged(addHistory = true): Promise<boolean> {
@@ -1001,9 +963,8 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._yamlEditor?.setValue(this.config);
}
this.readOnly = true;
this.errors = undefined;
} catch (err: any) {
this.errors = err.message;
showEditorToast(this, { message: err.message });
}
}
@@ -1116,7 +1077,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private async _saveAutomation(id): Promise<void> {
this.saving = true;
this.validationErrors = undefined;
const config = this.config!;
let entityRegPromise: Promise<EntityRegistryEntry> | undefined;
if (this.entityRegistryUpdate !== undefined && !this.currentEntityId) {
@@ -1127,7 +1088,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
}
try {
await saveAutomationConfig(this.hass, id, this.config!);
await saveAutomationConfig(this.hass, id, config);
if (this.entityRegistryUpdate !== undefined) {
let entityId = this.currentEntityId;
@@ -1162,7 +1123,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
}
if (entityId) {
await updateEntityRegistryEntry(this.hass, entityId, {
await updateEntityRegistryEntry(this.hass.callWS, entityId, {
categories: {
automation: this.entityRegistryUpdate.category || null,
},
@@ -1174,11 +1135,9 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._markDirtyStateClean();
this.deprecatedConfigMigrated = false;
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showEditorToast(this, {
message: errors.body?.message || errors.error || errors.body,
});
this.clearConfigErrors();
} catch (errors: unknown) {
this.showSaveErrors(errors, config);
throw errors;
} finally {
this.saving = false;
@@ -1267,7 +1267,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
this._selected.forEach((entityId) => {
promises.push(
updateEntityRegistryEntry(this.hass, entityId, {
updateEntityRegistryEntry(this.hass.callWS, entityId, {
categories: { automation: category },
})
);
@@ -1306,7 +1306,7 @@ ${rejected
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
this._selected.forEach((entityId) => {
promises.push(
updateEntityRegistryEntry(this.hass, entityId, {
updateEntityRegistryEntry(this.hass.callWS, entityId, {
labels:
action === "add"
? this.hass.entities[entityId].labels.concat(label)
@@ -1351,7 +1351,7 @@ ${rejected
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
this._selected.forEach((entityId) => {
promises.push(
updateEntityRegistryEntry(this.hass, entityId, {
updateEntityRegistryEntry(this.hass.callWS, entityId, {
area_id: area,
})
);
@@ -4,16 +4,26 @@ import type {
PropertyValues,
TemplateResult,
} from "lit";
import { css, html } from "lit";
import { property, state } from "lit/decorators";
import { css, html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { property, query, state } from "lit/decorators";
import { consume } from "../../../common/decorators/consume";
import { transform } from "../../../common/decorators/transform";
import { goBack, navigate } from "../../../common/navigate";
import { debounce } from "../../../common/util/debounce";
import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-alert";
import "../../../components/ha-spinner"; // used by renderLoading() provided to both editors
import type { AutomationMigrationReport } from "../../../data/automation";
import {
parseConfigSaveError,
validateConfigErrors,
type ConfigValidationError,
type ValidationSectionKeys,
} from "../../../data/config";
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
showAlertDialog,
@@ -22,7 +32,28 @@ import {
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import type { Constructor, HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import {
errorsEqual,
errorsOnKnownItems,
errorText,
followErrors,
followKnownErrorItems,
knownErrorItems,
mapErrorsToConfig,
NO_ERRORS,
type KnownErrorItem,
} from "./automation-errors";
import type { EntityRegistryUpdate } from "./automation-save-dialog/show-dialog-automation-save";
import { showEditorToast } from "./editor-toast";
const CONFIG_ERRORS_TOAST_ID = "config-errors";
const REVALIDATE_DELAY = 500;
interface ErrorsEditorElement extends HTMLElement {
revealErrors(): Promise<void>;
}
/** Minimum config shape shared by both AutomationConfig and ScriptConfig. */
interface BaseEditorConfig {
@@ -57,6 +88,10 @@ export const automationScriptEditorStyles: CSSResult = css`
p {
margin-bottom: 0;
}
.config-errors p {
margin: 0;
white-space: pre-wrap;
}
ha-button[slot="fab"] {
position: fixed;
right: calc(16px + var(--safe-area-inset-right, 0px));
@@ -82,7 +117,11 @@ export const automationScriptEditorStyles: CSSResult = css`
export interface EditorDomainHooks<TConfig> {
fetchFileConfig(hass: HomeAssistant, id: string): Promise<TConfig>;
normalizeConfig(raw: TConfig, report?: AutomationMigrationReport): TConfig;
checkValidation(): Promise<void>;
/** The parts of the config `validate_config` checks, none for blueprints. */
validationSections(
config: TConfig
): Partial<Record<"triggers" | "conditions" | "actions", unknown>>;
validationSectionKeys?: ValidationSectionKeys;
domain: "automation" | "script";
}
@@ -111,8 +150,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
@consume({ context: fullEntitiesContext, subscribe: true })
entityRegistry?: EntityRegistryEntry[];
@state() protected errors?: string;
@state() protected yamlErrors?: string;
@state() protected currentEntityId?: string;
@@ -125,7 +162,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
@state() protected deprecatedConfigMigrated = false;
@state() protected validationErrors?: (string | TemplateResult)[];
@state() protected configErrors: ConfigValidationError[] = NO_ERRORS;
@state() protected config?: TConfig;
@@ -157,6 +194,14 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
private _relatedContextAreaId?: string;
@query("manual-automation-editor, manual-script-editor")
private _errorsEditor?: ErrorsEditorElement;
// Validation responses of an older run are dropped
private _validationRun = 0;
private _knownErrorItems: KnownErrorItem[] = [];
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
@@ -167,6 +212,212 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
}
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("config") && this.configErrors.length) {
this._knownErrorItems = followKnownErrorItems(
this._knownErrorItems,
changedProps.get("config"),
this.config
);
this._revalidate();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._revalidate.cancel();
if (this.configErrors.length) {
this._closeErrorsToast();
}
}
/** Shows the errors of saving `submitted`, the config sent to core. */
protected async showSaveErrors(err: unknown, submitted: TConfig) {
const { message, errors } = parseConfigSaveError(err);
if (!errors.length) {
this._showSaveFailedToast(message);
return;
}
this._validationRun++;
const submittedErrors = mapErrorsToConfig(submitted, errors);
// Edits made while saving moved or removed some of the items
const changed = submitted !== this.config;
this._setConfigErrors(
this.config,
changed
? followErrors(submittedErrors, submitted, this.config)
: submittedErrors
);
if (changed) {
// Followed errors miss items that were renamed and moved, so check
// everything again
await this._validateConfig(false);
}
this._showSaveFailedToast(
errors.length === 1
? errors[0].message
: this.hass.localize(
"ui.panel.config.automation.editor.errors.problems_found",
{ count: errors.length }
)
);
await this._revealErrors();
}
protected async checkValidation() {
if (!this.currentEntityId || !this.config) {
return;
}
if (this.hass.states[this.currentEntityId]?.state !== UNAVAILABLE) {
return;
}
const config = this.config;
const sections = this.domainHooks.validationSections(config);
if (!Object.keys(sections).length) {
return;
}
const run = ++this._validationRun;
let errors: ConfigValidationError[];
try {
errors = await validateConfigErrors(
this.hass,
sections,
this.domainHooks.validationSectionKeys
);
} catch (_err: unknown) {
// The unavailable alert still shows, only the rows stay unmarked
return;
}
if (run !== this._validationRun || config !== this.config) {
return;
}
this._setConfigErrors(config, mapErrorsToConfig(config, errors));
}
private _setConfigErrors(
config: TConfig | undefined,
errors: ConfigValidationError[]
) {
this.configErrors = errors;
this._knownErrorItems = knownErrorItems(config, errors);
}
private async _revealErrors() {
await this.updateComplete;
if (this.mode === "gui") {
await this._errorsEditor?.revealErrors();
}
}
/** All errors, for the editors that have no rows to show them on. */
protected renderConfigErrorsAlert(slot?: string) {
return this.configErrors.length
? html`<ha-alert
alert-type="error"
class="config-errors"
slot=${ifDefined(slot)}
>
${this.configErrors.map((error) => html`<p>${errorText(error)}</p>`)}
</ha-alert>`
: nothing;
}
private _showSaveFailedToast(problems: string) {
showEditorToast(this, {
id: CONFIG_ERRORS_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.automation.editor.errors.save_failed",
{ problems }
),
});
}
private _closeErrorsToast() {
// A removed editor can't reach the toast manager by bubbling
const host = this.isConnected
? this
: document.querySelector<HTMLElement>("home-assistant");
if (!host) {
return;
}
showToast(host, {
id: CONFIG_ERRORS_TOAST_ID,
message: "",
duration: 0,
});
}
// Updates the errors of the items that had one, so fixed problems clear
// while editing. New problems elsewhere, and errors outside the validated
// sections like an invalid mode, wait for the next save.
private _revalidate = debounce(() => {
this._validateConfig(true);
}, REVALIDATE_DELAY);
/**
* Validates the sections of the current config. With `onlyKnown`, only
* errors on items that already had one are kept.
*/
private async _validateConfig(onlyKnown: boolean) {
this._revalidate.cancel();
const config = this.config;
if (!config || (onlyKnown && !this.configErrors.length)) {
return;
}
const sections = this.domainHooks.validationSections(config);
// Blueprint inputs can't be validated on their own, so their errors
// only last until the next edit
if (!Object.keys(sections).length) {
this.clearConfigErrors();
return;
}
const sectionKeys = this.domainHooks.validationSectionKeys ?? {};
const run = ++this._validationRun;
let sectionErrors: ConfigValidationError[];
try {
sectionErrors = await validateConfigErrors(
this.hass,
sections,
sectionKeys
);
} catch (_err: unknown) {
return;
}
if (run !== this._validationRun || config !== this.config) {
return;
}
const mapped = mapErrorsToConfig(config, sectionErrors);
const validated = Object.keys(sections).map(
(key) => sectionKeys[key] ?? key
);
const errors = [
...this.configErrors.filter(
(error) => !validated.includes(error.path[0] as string)
),
...(onlyKnown
? errorsOnKnownItems(this._knownErrorItems, config, mapped)
: mapped),
];
if (!errors.length) {
this.clearConfigErrors();
return;
}
this._setConfigErrors(
config,
errorsEqual(errors, this.configErrors) ? this.configErrors : errors
);
}
protected clearConfigErrors() {
this._validationRun++;
this._revalidate.cancel();
if (this.configErrors.length) {
this.configErrors = NO_ERRORS;
this._closeErrorsToast();
}
}
private _setRelatedContext(): void {
const areaId = this.currentEntityId
? this.entityRegistry?.find(
@@ -277,6 +528,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected async loadConfig(id: string) {
const hooks = this.domainHooks;
const domain = hooks.domain;
this.clearConfigErrors();
try {
const config = await hooks.fetchFileConfig(this.hass, id);
if (!this.isConnected) {
@@ -296,7 +548,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
hooks.checkValidation();
this.checkValidation();
} catch (err: any) {
if (!this.isConnected) {
return;
@@ -1,3 +1,4 @@
import { ContextProvider } from "@lit/context";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { tinykeys } from "tinykeys";
@@ -5,6 +6,7 @@ import { consume } from "../../../common/decorators/consume";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeRTL } from "../../../common/util/compute_rtl";
import { pathEquals } from "../../../common/util/config-path";
import "../../../components/ha-snap-bottom-sheet";
import type { HaSnapBottomSheet } from "../../../components/ha-snap-bottom-sheet";
import {
@@ -22,6 +24,11 @@ import { manifestsContext } from "../../../data/context";
import type { DomainManifestLookup } from "../../../data/integration";
import { isTriggerList } from "../../../data/trigger";
import type { HomeAssistant } from "../../../types";
import {
automationPathContext,
automationRowRegistryContext,
type AutomationRowRegistry,
} from "./automation-path";
import "./sidebar/ha-automation-sidebar-action";
import type HaAutomationSidebarAction from "./sidebar/ha-automation-sidebar-action";
import "./sidebar/ha-automation-sidebar-condition";
@@ -61,6 +68,17 @@ export default class HaAutomationSidebar extends LitElement {
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: DomainManifestLookup;
@consume({ context: automationRowRegistryContext })
private _registry?: AutomationRowRegistry;
// Editors in the sidebar are not inside their row, so they get its path here
private readonly _pathProvider = new ContextProvider(this, {
context: automationPathContext,
initialValue: [],
});
private _unsubscribeRegistry?: () => void;
@query("ha-snap-bottom-sheet")
private _bottomSheetElement?: HaSnapBottomSheet;
@@ -83,6 +101,28 @@ export default class HaAutomationSidebar extends LitElement {
private _tinykeysUnsub?: () => void;
public connectedCallback() {
super.connectedCallback();
// A row dragged to another place while it is open changes its path
this._unsubscribeRegistry = this._registry?.subscribe(() =>
this._syncPath()
);
}
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (changedProperties.has("config")) {
this._syncPath();
}
}
private _syncPath() {
const path = this.config ? (this._registry?.selectedPath() ?? []) : [];
if (!pathEquals(path, this._pathProvider.value)) {
this._pathProvider.setValue(path);
}
}
protected updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (
@@ -105,6 +145,8 @@ export default class HaAutomationSidebar extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeRegistry?.();
this._unsubscribeRegistry = undefined;
this._unregisterResizeHandlers();
this._tinykeysUnsub?.();
}
@@ -7,6 +7,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { nextRender } from "../../../common/util/render-status";
import type { AutomationClipboard } from "../../../data/automation";
import type { Constructor, HomeAssistant } from "../../../types";
import { AutomationPathController } from "./automation-path";
const rowKeys = new WeakMap<object, string>();
@@ -75,6 +76,13 @@ export const AutomationSortableListMixin = <T extends object>(
@property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar =
false;
/** Key of this list in the config of the row or editor rendering it. */
@property({ attribute: "path-key" }) public pathKey?: string;
protected readonly pathController = new AutomationPathController(this, {
segment: () => (this.pathKey === undefined ? [] : [this.pathKey]),
});
@state() protected rowSortSelected?: number;
@state()
@@ -1,8 +1,9 @@
import { provide } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import {
html,
nothing,
type LitElement,
type nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
@@ -15,6 +16,7 @@ import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../../common/navigate";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import { nextRender } from "../../../common/util/render-status";
import {
extractSearchParam,
removeSearchParam,
@@ -23,6 +25,7 @@ import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import type { SidebarConfig } from "../../../data/automation";
import type { ConfigValidationError } from "../../../data/config";
import {
dirtyStateContext,
type DirtyStateContext,
@@ -33,6 +36,19 @@ import type {
ValueChangedEvent,
} from "../../../types";
import { showToast } from "../../../util/toast";
import "../../../components/ha-alert";
import {
automationErrorsContext,
errorOwner,
errorText,
NO_ERRORS,
} from "./automation-errors";
import {
AutomationRowRegistry,
automationRowRegistryContext,
type AutomationPathRow,
} from "./automation-path";
import { scrollRowIntoView } from "./scroll-row-into-view";
import "./ha-automation-sidebar";
import type HaAutomationSidebar from "./ha-automation-sidebar";
@@ -82,6 +98,13 @@ export const ManualEditorMixin = <TConfig>(
@query("ha-automation-sidebar")
protected sidebarElement?: HaAutomationSidebar;
@provide({ context: automationRowRegistryContext })
protected rowRegistry = new AutomationRowRegistry();
@provide({ context: automationErrorsContext })
@property({ attribute: false })
public configErrors: ConfigValidationError[] = NO_ERRORS;
declare protected collapsableElements?: NodeListOf<
Element & {
expandAll(): void;
@@ -95,16 +118,44 @@ export const ManualEditorMixin = <TConfig>(
throw new Error("Not implemented");
};
private _unsubscribeRows?: () => void;
// Rows register while they render, so errors only count as without a
// row once the rows had a frame to render
@state() private _rowsRendered = false;
public connectedCallback() {
super.connectedCallback();
window.addEventListener("paste", this.handlePaste);
// Which errors have a row changes as rows render
this._unsubscribeRows = this.rowRegistry.subscribe(() => {
if (this.configErrors.length) {
this.requestUpdate();
}
});
}
public disconnectedCallback() {
window.removeEventListener("paste", this.handlePaste);
this._unsubscribeRows?.();
this._unsubscribeRows = undefined;
super.disconnectedCallback();
}
private _renderUnownedErrors() {
if (!this._rowsRendered) {
return nothing;
}
const unowned = this.configErrors.filter(
(error) => !errorOwner(this.rowRegistry, error)
);
return unowned.length
? html`<ha-alert alert-type="error" class="unowned-errors">
${unowned.map((error) => html`<p>${errorText(error)}</p>`)}
</ha-alert>`
: nothing;
}
protected renderContent(): TemplateResult | typeof nothing {
throw new Error("Not implemented");
}
@@ -133,7 +184,7 @@ export const ManualEditorMixin = <TConfig>(
>
<div class="content">
<slot name="alerts"></slot>
${this.renderContent()}
${this._renderUnownedErrors()} ${this.renderContent()}
</div>
<div class="fab-positioner">
<ha-button
@@ -171,6 +222,9 @@ export const ManualEditorMixin = <TConfig>(
protected firstUpdated(changedProps: PropertyValues<this>): void {
super.firstUpdated(changedProps);
nextRender().then(() => {
this._rowsRendered = true;
});
this.style.setProperty(
"--sidebar-dynamic-width",
@@ -295,6 +349,54 @@ export const ManualEditorMixin = <TConfig>(
});
}
/**
* Expands the rows along the path of every error, then scrolls to the
* first one and opens it in the sidebar. Does nothing while a row with an
* error is open, so the user stays where they are.
*/
public async revealErrors() {
await this.updateComplete;
const errorRows = () =>
this.configErrors.map((error) =>
this.rowRegistry.rowsAlong(error.path)
);
const owners = (rows: AutomationPathRow[][]) =>
rows
.map((along) => along[along.length - 1])
.filter((owner) => owner !== undefined);
let rows = errorRows();
if (owners(rows).some((owner) => owner.selected)) {
return;
}
const depthOf = (along: AutomationPathRow[][]) =>
along.reduce((sum, path) => sum + path.length, 0);
// Collapsed rows don't render their children, so expand one level at a
// time until no deeper row shows up
let depth = -1;
while (depthOf(rows) > depth) {
depth = depthOf(rows);
rows.flat().forEach((row) => row.expand());
// eslint-disable-next-line no-await-in-loop
await nextRender();
rows = errorRows();
}
// Errors aren't in config order, so take the topmost row
const first = owners(rows).reduce<AutomationPathRow | undefined>(
(top, row) =>
!top ||
row.getBoundingClientRect().top < top.getBoundingClientRect().top
? row
: top,
undefined
);
if (!first) {
return;
}
first.openSidebar();
await nextRender();
scrollRowIntoView(first);
}
public expandAll() {
this.collapsableElements?.forEach((element) => {
element.expandAll();
@@ -111,6 +111,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
<ha-automation-trigger
role="region"
path-key="triggers"
aria-labelledby="triggers-heading"
.triggers=${this.config.triggers || []}
.highlightedTriggers=${this.pastedConfig?.triggers}
@@ -149,6 +150,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
<ha-automation-condition
role="region"
path-key="conditions"
aria-labelledby="conditions-heading"
.conditions=${this.config.conditions || []}
.highlightedConditions=${this.pastedConfig?.conditions}
@@ -183,6 +185,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
<ha-automation-action
role="region"
path-key="actions"
aria-labelledby="actions-heading"
.actions=${this.config.actions || []}
.highlightedActions=${this.pastedConfig?.actions}
@@ -64,6 +64,8 @@ import {
} from "../styles";
import "../trigger/ha-automation-trigger-references";
import { getUiSupportWarnings, isConfigObject } from "../ui-support";
import { AutomationPathController } from "../automation-path";
import { renderRowErrorIcon } from "../automation-row-error-icon";
@customElement("ha-automation-option-row")
export default class HaAutomationOptionRow extends LitElement {
@@ -89,6 +91,11 @@ export default class HaAutomationOptionRow extends LitElement {
@property({ type: Boolean, attribute: "sort-selected" })
public sortSelected = false;
protected readonly pathController = new AutomationPathController(this, {
segment: () => (this.defaultActions ? ["default"] : [this.index]),
row: () => (this.optionsInSidebar ? this : undefined),
});
@state() private _expanded = false;
@state() private _selected = false;
@@ -266,6 +273,11 @@ export default class HaAutomationOptionRow extends LitElement {
}
</h3>
${renderRowErrorIcon(
this.pathController.errors,
this.pathController.path,
this.hass.localize("ui.panel.config.automation.editor.errors.label")
)}
<slot name="icons" slot="icons"></slot>
${
@@ -448,6 +460,7 @@ export default class HaAutomationOptionRow extends LitElement {
)}:
</h4>
<ha-automation-condition
path-key="conditions"
.conditions=${ensureArray<string | Condition>(
this.option.conditions
)}
@@ -466,6 +479,7 @@ export default class HaAutomationOptionRow extends LitElement {
)}:
</h4>
<ha-automation-action
.pathKey=${this.option ? "sequence" : undefined}
.actions=${
(this.option
? ensureArray(this.option.sequence) || []
@@ -494,6 +508,7 @@ export default class HaAutomationOptionRow extends LitElement {
.collapsed=${this._collapsed}
.selected=${this._selected}
.sortSelected=${this.sortSelected}
.error=${this.pathController.errors.length > 0}
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
@delete-row=${this._removeOption}
@@ -1,3 +1,4 @@
import { provide } from "@lit/context";
import { mdiClose, mdiDotsVertical } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
@@ -12,6 +13,13 @@ import "../../../../components/ha-icon-button";
import { ScrollableFadeMixin } from "../../../../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../../../components/ha-alert";
import { errorField, errorText } from "../automation-errors";
import {
automationInlineErrorFieldsContext,
AutomationFieldErrorsController,
InlineErrorFields,
} from "../automation-field-errors";
import "../ha-automation-editor-warning";
export interface SidebarOverflowMenuEntry {
@@ -44,11 +52,22 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
@query(".card-content") private _contentElement!: HTMLDivElement;
private readonly _errors = new AutomationFieldErrorsController(this);
@provide({ context: automationInlineErrorFieldsContext })
protected inlineErrorFields = new InlineErrorFields(() =>
this.requestUpdate()
);
protected get scrollableElement(): HTMLElement | null {
return this._contentElement;
}
protected render() {
const errors = this._errors.rowErrors.filter((error) => {
const field = errorField(error, this._errors.path);
return field === undefined || !this.inlineErrorFields.has(field);
});
return html`
<ha-card
outlined
@@ -90,6 +109,15 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
</ha-automation-editor-warning>`
: nothing
}
${
errors.length
? html`<ha-alert alert-type="error" class="errors">
${errors.map(
(error) => html`<p>${errorText(error, this._errors.path)}</p>`
)}
</ha-alert>`
: nothing
}
<div class="card-content ha-scrollbar">
<slot></slot>
${this.renderScrollableFades(this.isWide)}
@@ -148,6 +176,15 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
padding-bottom: max(var(--safe-area-inset-bottom, 0px), 32px);
}
.errors {
margin: 0 var(--ha-space-4) var(--ha-space-4);
}
.errors p {
margin: 0;
white-space: pre-wrap;
}
.fade-top {
top: var(--ha-space-17);
}
+22
View File
@@ -124,6 +124,19 @@ export const rowStyles = css`
white-space: pre-wrap;
margin: 0;
}
.error-indicator {
color: var(--ha-color-on-danger-quiet);
padding: var(--ha-space-3);
}
.error-indicator + ha-tooltip::part(body) {
cursor: default;
max-width: 300px;
}
.error-indicator + ha-tooltip p {
white-space: pre-wrap;
margin: 0;
}
`;
export const editorStyles = css`
@@ -280,6 +293,15 @@ export const manualEditorStyles = css`
.header a {
color: var(--secondary-text-color);
}
.unowned-errors {
display: block;
margin-bottom: var(--ha-space-2);
}
.unowned-errors p {
margin: 0;
white-space: pre-wrap;
}
`;
export const automationRowsStyles = css`
@@ -120,6 +120,8 @@ import "./types/ha-automation-trigger-webhook";
import "./types/ha-automation-trigger-zone";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
import { renderRowErrorIcon } from "../automation-row-error-icon";
import { targetExpandGroupContext } from "../../../../components/target-picker/target-expand-group-context";
export interface TriggerElement extends LitElement {
@@ -164,7 +166,7 @@ export default class HaAutomationTriggerRow extends LitElement {
@property({ type: Boolean }) public last?: boolean;
@property({ type: Number }) public index?: number;
@property({ attribute: false }) public index!: number;
@property({ type: Boolean }) public highlight?: boolean;
@@ -174,6 +176,11 @@ export default class HaAutomationTriggerRow extends LitElement {
@property({ type: Boolean, attribute: "sort-selected" })
public sortSelected = false;
protected readonly pathController = new AutomationPathController(this, {
segment: () => [this.index],
row: () => (this.optionsInSidebar ? this : undefined),
});
@state() private _yamlMode = false;
@state() private _triggered = false;
@@ -394,6 +401,11 @@ export default class HaAutomationTriggerRow extends LitElement {
)}
</ha-automation-row-event-chip>
${renderRowErrorIcon(
this.pathController.errors,
this.pathController.path,
this.hass.localize("ui.panel.config.automation.editor.errors.label")
)}
<slot name="icons" slot="icons"></slot>
<ha-dropdown
@@ -658,6 +670,7 @@ export default class HaAutomationTriggerRow extends LitElement {
.selected=${this._selected}
.highlight=${this.highlight}
.sortSelected=${this.sortSelected}
.error=${this.pathController.errors.length > 0}
.dim=${this._triggered}
@click=${this._toggleSidebar}
>${
@@ -29,9 +29,18 @@ import {
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
import {
deviceAutomationErrorText,
deviceErrorText,
} from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
@customElement("ha-automation-trigger-device")
export class HaDeviceTrigger extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["device_id", "domain"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Object }) public trigger!: DeviceTrigger;
@@ -136,12 +145,20 @@ export class HaDeviceTrigger extends LitElement {
}
protected render() {
const deviceError = this._fieldErrors.fieldError("device_id");
const automationError = this._fieldErrors.fieldError("domain");
const deviceId = this._deviceId || this.trigger.device_id;
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.invalid=${!!deviceError}
.errorMessage=${
deviceError
? deviceErrorText(this.hass.localize, deviceError)
: undefined
}
@value-changed=${this._devicePicked}
.disabled=${this.disabled}
.label=${this.hass.localize(
@@ -149,6 +166,16 @@ export class HaDeviceTrigger extends LitElement {
)}
></ha-device-picker>
<ha-device-trigger-picker
.invalid=${!!automationError}
.errorMessage=${
automationError
? deviceAutomationErrorText(
this.hass.localize,
"triggers",
automationError
)
: undefined
}
.value=${this.trigger}
.deviceId=${deviceId}
@value-changed=${this._deviceTriggerPicked}
@@ -26,6 +26,7 @@ export class HaTriggerList extends LitElement implements TriggerElement {
return html`
<ha-automation-trigger
path-key="triggers"
.triggers=${triggers}
.hass=${this.hass}
.disabled=${this.disabled}
@@ -4,6 +4,7 @@ import memoizeOne from "memoize-one";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { NumericStateTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
@@ -183,6 +184,17 @@ const SCHEMA = [
@customElement("ha-automation-trigger-numeric_state")
export class HaNumericStateTrigger extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: [
"entity_id",
"attribute",
"value_template",
"above",
"below",
"for",
],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: NumericStateTrigger;
@@ -230,6 +242,7 @@ export class HaNumericStateTrigger extends LitElement {
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
@@ -23,6 +23,9 @@ import {
} from "../../../../../data/trigger";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
import "../../../../../components/ha-input-helper-text";
import { targetErrorText } from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import { targetExpandGroupContext } from "../../../../../components/target-picker/target-expand-group-context";
const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
@@ -43,6 +46,10 @@ export class HaPlatformTrigger extends LitElement {
@provide({ context: targetExpandGroupContext })
protected _targetExpandGroup = false;
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["target"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: PlatformTrigger;
@@ -226,6 +233,7 @@ export class HaPlatformTrigger extends LitElement {
></ha-selector>`
: nothing
}
${this._renderTargetError()}
${
shouldRenderDataYaml
? html`<ha-yaml-editor
@@ -250,6 +258,15 @@ export class HaPlatformTrigger extends LitElement {
`;
}
private _renderTargetError() {
const error = this._fieldErrors.fieldError("target");
return error
? html`<ha-input-helper-text class="target-error">
${targetErrorText(this.hass.localize, error)}
</ha-input-helper-text>`
: nothing;
}
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector["target"] | null | undefined) =>
targetSelector ? { target: { ...targetSelector } } : { target: {} }
@@ -558,6 +575,10 @@ export class HaPlatformTrigger extends LitElement {
1px solid var(--divider-color)
);
}
.target-error {
color: var(--ha-color-on-danger-quiet);
padding-bottom: var(--ha-space-2);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
@@ -20,6 +20,7 @@ import { hasTemplate } from "../../../../../common/string/has-template";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import { ANY_STATE_VALUE } from "../../../../../components/entity/const";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type {
HaFormSchema,
SchemaUnion,
@@ -57,6 +58,10 @@ const stateTriggerStruct = assign(
@customElement("ha-automation-trigger-state")
export class HaStateTrigger extends LitElement implements TriggerElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["entity_id", "attribute", "from", "to", "for"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: StateTrigger;
@@ -311,6 +316,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${data}
.schema=${schema}
@@ -3,6 +3,7 @@ import { customElement, property } from "lit/decorators";
import type { TemplateTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
@@ -25,6 +26,10 @@ const SCHEMA = [
@customElement("ha-automation-trigger-template")
export class HaTemplateTrigger extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["value_template", "for"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: TemplateTrigger;
@@ -54,6 +59,7 @@ export class HaTemplateTrigger extends LitElement {
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
@@ -216,7 +216,7 @@ class HaConfigBackupOverview extends LitElement {
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.backup.overview.header")}
.header=${this.hass.localize("ui.panel.config.backup.caption")}
>
<ha-dropdown
slot="toolbar-icon"
@@ -123,7 +123,7 @@ class DialogAssignCategory extends DirtyStateProviderMixin<AssignCategoryFormSta
this._error = undefined;
try {
await updateEntityRegistryEntry(
this.hass,
this.hass.callWS,
this._params!.entityReg.entity_id,
{
categories: { [this._scope!]: this._category || null },
@@ -17,6 +17,8 @@ import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
const ADDRESS_TYPES = ["mac", "bluetooth", "zigbee"];
@customElement("ha-device-info-card")
export class HaDeviceCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -234,9 +236,13 @@ export class HaDeviceCard extends LitElement {
}
protected _getAddresses() {
return this.device.connections.filter((conn) =>
["mac", "bluetooth", "zigbee"].includes(conn[0])
);
return this.device.connections
.filter((conn) => ADDRESS_TYPES.includes(conn[0]))
.sort(
(a, b) =>
ADDRESS_TYPES.indexOf(a[0]) - ADDRESS_TYPES.indexOf(b[0]) ||
stringCompare(a[1], b[1])
);
}
private _computeDeviceNameDisplay(deviceId: string) {
@@ -1600,7 +1600,11 @@ export class HaConfigDevicePage extends LitElement {
delete updates.disabled_by;
}
try {
await updateDeviceRegistryEntry(this.hass, this.deviceId, updates);
await updateDeviceRegistryEntry(
this.hass.callWS,
this.deviceId,
updates
);
} catch (err: unknown) {
showAlertDialog(this, {
title: this.hass.localize(
@@ -1616,7 +1620,7 @@ export class HaConfigDevicePage extends LitElement {
};
private async _enableDevice(): Promise<void> {
await updateDeviceRegistryEntry(this.hass, this.deviceId, {
await updateDeviceRegistryEntry(this.hass.callWS, this.deviceId, {
disabled_by: null,
});
}
@@ -1167,7 +1167,7 @@ export class HaConfigDeviceDashboard extends LitElement {
const promises: Promise<DeviceRegistryEntry>[] = [];
this._selected.forEach((deviceId) => {
promises.push(
updateDeviceRegistryEntry(this.hass, deviceId, {
updateDeviceRegistryEntry(this.hass.callWS, deviceId, {
area_id: area,
})
);
@@ -1219,7 +1219,7 @@ ${rejected
const promises: Promise<DeviceRegistryEntry>[] = [];
this._selected.forEach((deviceId) => {
promises.push(
updateDeviceRegistryEntry(this.hass, deviceId, {
updateDeviceRegistryEntry(this.hass.callWS, deviceId, {
labels:
action === "add"
? this.hass.devices[deviceId].labels.concat(label)
@@ -114,7 +114,7 @@ export class DialogVacuumSegmentMapping
last_seen_segments: mapper.lastSeenSegments,
};
await updateEntityRegistryEntry(this.hass, this._params.entityId, {
await updateEntityRegistryEntry(this.hass.callWS, this._params.entityId, {
options_domain: "vacuum",
options: options,
});
@@ -100,7 +100,6 @@ export class EntitySettingsHelperTab extends LitElement {
: html`
<span @value-changed=${this._valueChanged}>
${dynamicElement(`ha-${this.entry.platform}-form`, {
hass: this.hass,
item: this._item,
entry: this.entry,
disabled: this._item === null,
@@ -1414,7 +1414,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
const result = await updateEntityRegistryEntry(
this.hass!,
this.hass!.callWS,
this.entry.entity_id,
params
);
@@ -1593,7 +1593,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
private async _restoreEntityId(): Promise<void> {
// use the unsaved name and area, as they are part of the entity ID
const entityIds = await getAutomaticEntityIds(
this.hass,
this.hass.callWS,
[this._origEntityId],
{
[this._origEntityId]: {
@@ -1821,7 +1821,11 @@ export class EntityRegistrySettingsEditor extends LitElement {
showDeviceRegistryDetailDialog(this, {
device: this._device!,
updateEntry: async (updates) => {
await updateDeviceRegistryEntry(this.hass, this._device!.id, updates);
await updateDeviceRegistryEntry(
this.hass.callWS,
this._device!.id,
updates
);
},
});
}
@@ -179,7 +179,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
showDeviceRegistryDetailDialog(this, {
device: device,
updateEntry: async (updates) => {
await updateDeviceRegistryEntry(this.hass, device.id, updates);
await updateDeviceRegistryEntry(this.hass.callWS, device.id, updates);
},
});
}
@@ -189,7 +189,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
this._submitting = true;
try {
const result = await updateEntityRegistryEntry(
this.hass!,
this.hass!.callWS,
this.entry.entity_id,
{ disabled_by: null }
);
@@ -1388,7 +1388,7 @@ export class HaConfigEntities extends LitElement {
const result = await Promise.allSettled(
this._selected.map(async (entity) => {
const updateResult = await updateEntityRegistryEntry(
this.hass,
this.hass.callWS,
entity,
{
disabled_by: null,
@@ -1453,7 +1453,7 @@ export class HaConfigEntities extends LitElement {
dismissText: this.hass.localize("ui.common.cancel"),
confirm: () => {
this._selected.forEach((entity) =>
updateEntityRegistryEntry(this.hass, entity, {
updateEntityRegistryEntry(this.hass.callWS, entity, {
disabled_by: "user",
})
);
@@ -1475,7 +1475,7 @@ export class HaConfigEntities extends LitElement {
dismissText: this.hass.localize("ui.common.cancel"),
confirm: () => {
this._selected.forEach((entity) =>
updateEntityRegistryEntry(this.hass, entity, {
updateEntityRegistryEntry(this.hass.callWS, entity, {
hidden_by: "user",
})
);
@@ -1486,7 +1486,7 @@ export class HaConfigEntities extends LitElement {
private _unhideSelected = () => {
this._selected.forEach((entity) =>
updateEntityRegistryEntry(this.hass, entity, {
updateEntityRegistryEntry(this.hass.callWS, entity, {
hidden_by: null,
})
);
@@ -1523,7 +1523,7 @@ export class HaConfigEntities extends LitElement {
return;
}
promises.push(
updateEntityRegistryEntry(this.hass, entityId, {
updateEntityRegistryEntry(this.hass.callWS, entityId, {
labels:
action === "add"
? entityReg.labels.concat(label)
@@ -1,11 +1,13 @@
import { mdiAlertOutline } from "@mdi/js";
import Fuse from "fuse.js";
import type { HassConfig } from "home-assistant-js-websocket";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { consume } from "../../../common/decorators/consume";
import { transform } from "../../../common/decorators/transform";
import { dynamicElement } from "../../../common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -29,6 +31,12 @@ import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import { getConfigFlowHandlers } from "../../../data/config_flow";
import {
apiContext,
configContext,
connectionContext,
internationalizationContext,
} from "../../../data/context";
import { createCounter } from "../../../data/counter";
import {
fetchEntityRegistry,
@@ -58,7 +66,13 @@ import {
type FuseWeightedKey,
} from "../../../resources/fuseMultiTerm";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import type {
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantInternationalization,
ValueChangedEvent,
} from "../../../types";
import type {
Helper,
HelperCategory,
@@ -77,7 +91,7 @@ type HelperCreators = Record<
HelperDomain,
{
create: (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
// Not properly typed because there is currently a mismatch for this._item between:
// 1. Type passed to form should be Helper
// 2. Type received by creator should be MutableParams version
@@ -147,7 +161,25 @@ const HELPER_SEARCH_KEYS: FuseWeightedKey[] = [
export class DialogHelperDetail extends DirtyStateProviderMixin<
{ item?: HelperFormItem; area?: string } | undefined
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@consume({ context: connectionContext, subscribe: true })
@transform<HomeAssistantConnection, Connection>({
transformer: ({ connection }) => connection,
})
private _connection!: Connection;
@state() private _item?: HelperFormItem;
@@ -203,9 +235,9 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
}
this._open = true;
await this.updateComplete;
this.hass.loadFragmentTranslation("config");
const flows = await getConfigFlowHandlers(this.hass, "helper");
await this.hass.loadBackendTranslation("title", flows, true);
this._i18n.loadFragmentTranslation("config");
const flows = await getConfigFlowHandlers(this._api.callApi, "helper");
await this._i18n.loadBackendTranslation("title", flows, true);
// Ensure the titles are loaded before we render the flows.
this._helperFlows = flows;
@@ -214,13 +246,13 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
this._focusPicker();
}
protected updated(changedProps: PropertyValues<this>): void {
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
const oldHass = changedProps.get("hass");
const oldI18n = changedProps.get("_i18n") as
HomeAssistantInternationalization | undefined;
if (
oldHass &&
(oldHass.localize !== this.hass.localize ||
oldHass.config !== this.hass.config)
(oldI18n && oldI18n.localize !== this._i18n.localize) ||
changedProps.get("_hassConfig") !== undefined
) {
this._comboBox?.refreshItems();
}
@@ -272,7 +304,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
<div class="form" @value-changed=${this._valueChanged}>
${this._error ? html`<div class="error">${this._error}</div>` : ""}
${dynamicElement(`ha-${this._domain}-form`, {
hass: this.hass,
item: this._item,
new: true,
autofocus: true,
@@ -295,7 +326,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
@click=${this._goBack}
.disabled=${this._submitting}
>
${this.hass!.localize("ui.common.back")}
${this._i18n.localize("ui.common.back")}
</ha-button>`
}
<ha-button
@@ -303,7 +334,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
@click=${this._createItem}
.disabled=${this._submitting}
>
${this.hass!.localize("ui.panel.config.helpers.dialog.create")}
${this._i18n.localize("ui.panel.config.helpers.dialog.create")}
</ha-button>
</ha-dialog-footer>
`;
@@ -317,16 +348,16 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
mode="dialog"
.shown=${this._shown}
no-sort
.label=${this.hass.localize(
.label=${this._i18n.localize(
"ui.panel.config.integrations.search_helper"
)}
.getItems=${this._getItems}
.rowRenderer=${this._renderHelperRow}
.sections=${this._getSections(
this._helperFlows,
this.hass.localize,
this.hass.config,
this.hass.locale.language
this._i18n.localize,
this._hassConfig,
this._i18n.locale.language
)}
.selectedSection=${this._category}
@index-selected=${this._helperPicked}
@@ -342,12 +373,12 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
.preventScrimClose=${this.isDirtyState}
header-title=${
this._domain
? this.hass.localize(
? this._i18n.localize(
"ui.panel.config.helpers.dialog.create_platform",
{
platform:
(isHelperDomain(this._domain) &&
this.hass.localize(
this._i18n.localize(
`ui.panel.config.helpers.types.${
this._domain as HelperDomain
}`
@@ -355,7 +386,9 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
this._domain,
}
)
: this.hass.localize("ui.panel.config.helpers.dialog.create_helper")
: this._i18n.localize(
"ui.panel.config.helpers.dialog.create_helper"
)
}
@after-show=${this._dialogShown}
@closed=${this._dialogClosed}
@@ -441,9 +474,9 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
this._search = search;
const allItems = this._getHelperItems(
this._helperFlows ?? [],
this.hass.localize,
this.hass.config,
this.hass.locale.language
this._i18n.localize,
this._hassConfig,
this._i18n.locale.language
);
const items = search
? multiTermSortedSearch(
@@ -463,7 +496,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
const categoryItems = items.filter((item) => item.category === category);
return categoryItems.length
? [
this.hass.localize(
this._i18n.localize(
`ui.panel.config.helpers.dialog.categories.${category}`
),
...categoryItems,
@@ -498,7 +531,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
@click=${stopPropagation}
></ha-svg-icon>
<ha-tooltip slot="end" .for="icon-${domain}">
${this.hass.localize(
${this._i18n.localize(
"ui.dialogs.helper_settings.platform_not_loaded",
{ platform: domain }
)}
@@ -523,7 +556,10 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
this._submitting = true;
this._error = "";
try {
const createdEntity = await HELPERS[domain].create(this.hass, this._item);
const createdEntity = await HELPERS[domain].create(
this._api.callWS,
this._item
);
// The dialog was closed or reopened while waiting
if (this._params !== params) {
return;
@@ -565,13 +601,13 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
try {
// The entity ID usually matches the helper ID, unless it was taken
const entry = await getExtendedEntityRegistryEntry(
this.hass,
this._api,
`${domain}.${uniqueId}`
).catch(() => undefined);
if (entry && isCreated(entry)) {
return entry.entity_id;
}
return (await fetchEntityRegistry(this.hass.connection)).find(isCreated)
return (await fetchEntityRegistry(this._connection)).find(isCreated)
?.entity_id;
} catch (_err) {
return undefined;
@@ -585,7 +621,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
let error = "Entity not found";
if (entityId) {
try {
return (await updateEntitiesArea(this.hass, [entityId], areaId))[
return (await updateEntitiesArea(this._api.callWS, [entityId], areaId))[
entityId
];
} catch (err: unknown) {
@@ -593,7 +629,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
}
}
showAlertDialog(this, {
text: this.hass.localize(
text: this._i18n.localize(
"ui.panel.config.integrations.config_flow.error_saving_entity",
{ error }
),
@@ -614,7 +650,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
const { id: domain, loaded } = ev.detail.item as HelperPickerItem;
if (!loaded) {
showAlertDialog(this, {
text: this.hass.localize(
text: this._i18n.localize(
"ui.dialogs.helper_settings.platform_not_loaded",
{ platform: domain }
),
@@ -637,7 +673,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
} else {
this._pendingConfigFlow = {
startFlowHandler: domain,
manifest: await fetchIntegrationManifest(this.hass, domain),
manifest: await fetchIntegrationManifest(this._api, domain),
dialogClosedCallback: this._params?.dialogClosedCallback,
navigateToResult: this._params?.navigateToResult,
};
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more