Compare commits

...

83 Commits

Author SHA1 Message Date
Wendelin 6b010b7ada Migrate getTriggerInfos to hassless 2026-05-26 09:12:17 +02:00
Jan-Philipp Benecke a147fc4fee Fix padding of vertical tile card content (#52198) 2026-05-26 08:21:05 +03:00
karwosts a300085208 Fix calendar panel for non-admin (#52203) 2026-05-26 08:20:26 +03:00
renovate[bot] 44989a6972 Update dependency date-fns to v4.3.0 (#52205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 08:19:30 +03:00
Yosi Levy 54a8e6c294 RTL fix for automation row (#52200) 2026-05-25 12:38:34 +02:00
Yosi Levy bfec22d828 RTL fix for new suggestion tree (#52199) 2026-05-25 11:52:54 +02:00
steven cde6450cfc Fix stale wake word display after wake word change in voice satellite set up wizard (#52194)
Fix stale wake word display after wake word change in satellite wizard

The config re-fetch was fire-and-forgotten, so the step transition to
STEP.WAKEWORD raced ahead with stale assistConfiguration. Awaiting the
fetch ensures the fresh active_wake_words are in place before rendering.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:41:56 +00:00
Petar Petrov ab39e70629 Recover brand icons after Home Assistant restart (#52158)
* Recover brand icons after Home Assistant restart

* Make _refreshBrandsAccessToken async
2026-05-25 09:36:50 +02:00
J. Nick Koston 69f209e3c3 Teach Bluetooth UI about auto scanning mode (#52192)
* Teach Bluetooth UI about auto scanning mode

* Drop unreachable auto cases and add isScannerStateMismatch tests
2026-05-25 09:30:03 +02:00
J. Nick Koston f4c5561a54 Show raw advertisement bytes in Bluetooth device info (#52193)
* Show raw advertisement bytes in Bluetooth device info

* Use plain div for raw hex to avoid fragile pre whitespace
2026-05-25 09:24:46 +02:00
renovate[bot] 5147937a6f Update dependency generate-license-file to v4.2.1 (#52195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 09:24:42 +02:00
renovate[bot] ee39605aa7 Update dependency intl-messageformat to v11.2.7 (#52197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 09:24:38 +02:00
renovate[bot] 4af4f1dc51 Update dependency idb-keyval to v6.2.4 (#52190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 14:01:55 +00:00
renovate[bot] a2d8859d94 Update dependency @date-fns/tz to v1.5.0 (#52187)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 14:03:30 +03:00
renovate[bot] afea8180c4 Update dependency idb-keyval to v6.2.3 (#52186)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 13:53:50 +03:00
dependabot[bot] b9c077489d Bump github/codeql-action from 4.35.4 to 4.35.5 (#52183)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.4 to 4.35.5.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/68bde559dea0fdcac2102bfdf6230c5f70eb485e...9e0d7b8d25671d64c341c19c0152d693099fb5ba)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 09:33:38 +03:00
karwosts 440bb32056 Remove unintended sort from select selector (#52179) 2026-05-23 21:14:24 +02:00
renovate[bot] 8f371621ad Update dependency @rspack/core to v2.0.4 (#52178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 16:18:20 +03:00
renovate[bot] 61815b20e3 Update vitest monorepo to v4.1.7 (#52173)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 13:03:54 +02:00
ildar170975 1942fa3a77 hui-entity-editor: fix vertical spacings (#52170)
fix spacings
2026-05-23 10:04:46 +02:00
renovate[bot] 865e67a06f Update Yarn to v4.15.0 (#52169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 10:03:23 +02:00
renovate[bot] 412dce4c1f Update dependency tinykeys to v4 (#52172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 10:02:58 +02:00
Jan-Philipp Benecke ced2ac7ad5 Fix ha-drawer z-index (#52167)
Fix ha-drawer index
2026-05-22 20:33:05 +02:00
renovate[bot] 6649f52bcd Update dependency tinykeys to v3.1.0 (#52166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 17:38:26 +00:00
Pascal Vizeli 7dbd6ae5a2 Add-on iframe: delegate microphone + camera Permissions Policy (#52068)
* Add-on iframe: delegate microphone + camera Permissions Policy

The add-on ingress iframe in ``ha-panel-app.ts`` ships without an
``allow=`` attribute, so the Permissions Policy default of *deny*
applies for ``microphone`` and ``camera`` on the cross-origin
iframe. An add-on that wants to call ``getUserMedia`` — voice
notes, dictation, video calls, photo capture — fails silently with
``NotAllowedError`` before the browser even surfaces the permission
prompt.

The failure is most visible on the Android Companion app, where
there's no "open in a new tab" escape: the user presses the mic
button and nothing happens, no toast, no logs.

Delegate ``microphone``, ``camera``, and ``clipboard-write`` to the
add-on iframe. Add-ons are first-party software the user explicitly
installs, and Chrome's runtime permission prompt still gates the
hardware access — the ``allow=`` attribute just lets the iframe
*request* the prompt instead of being blocked at the policy layer.

``clipboard-write`` is bundled in because the next-most-frequent
silent-fail in add-on land is ``navigator.clipboard.writeText`` for
"copy link" / "copy code" affordances, blocked by the same
mechanism.

* Sandbox add-on ingress iframe without allow-same-origin

Split IFRAME_SANDBOX into two constants: IFRAME_SANDBOX (without
allow-same-origin) for add-on ingress iframes that need origin
isolation, and IFRAME_SANDBOX_SAME_ORIGIN for external iframes
that need same-origin access.

This ensures add-on iframes can't inherit camera/microphone
permissions already granted to the Home Assistant origin, and
prevents same-origin iframes from removing their own sandbox.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 19:31:56 +02:00
Wendelin e1528d21b3 Migrate md-lists cloud dashboard and devtools (#52163)
Migrate lists in cloud and dev tools=
2026-05-22 18:24:16 +03:00
renovate[bot] 79cb3137f2 Update tsparticles to v4.0.5 (#52162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 18:17:04 +03:00
Jan-Philipp Benecke 313360701a Revamp ZHA group page UI (#52124) 2026-05-22 16:24:59 +02:00
renovate[bot] b100d9577d Update formatjs monorepo (#52159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 15:56:02 +03:00
Wendelin 44ce303302 Fix dropdown keyboard scroll (#52157) 2026-05-22 13:47:43 +01:00
Aidan Timson 8f76613068 Add more quick links to device page (#52137)
* Add more quick links to device page

* Move shared keys to common location
2026-05-22 12:45:37 +03:00
Aidan Timson 85dff6640a Use ha-list-nav for each section in device page related card (#52142)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-05-22 11:22:36 +02:00
Aidan Timson ab7c892b6b Add to for area page (#52141)
* Setup add to area page

* Remove 3 buttons, move to single add to button next to add a picture button

* Use normal size buttons

* Restructure layout with picture

* Remove div when both conditions are met

* Use mixin

* Fix imports
2026-05-22 12:02:38 +03:00
Wendelin 3fe57ad724 webawesome 3.7.0 (#52155) 2026-05-22 11:24:16 +03:00
pcan08 1caf1d99b5 Migrate theme-picker to ha-generic-picker (#52067)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Wendelin <w@pe8.at>
2026-05-22 06:32:39 +00:00
renovate[bot] 483df2fa2f Update dependency marked to v18.0.4 (#52153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:52:04 +03:00
Stefan Agner e0adb006b6 Show time zone picker in onboarding when browser can't resolve IANA zone (#52146)
Some environments (e.g. Android WebView/emulator) return a UTC offset like
"+00:00" from Intl.DateTimeFormat().resolvedOptions().timeZone instead of an
IANA zone name. Submitting that to saveCoreConfig fails with "invalid time
zone", leaving users stuck on the country step.

Detect this by checking the resolved value against the google-timezones-json
list used by ha-timezone-picker, and surface the picker on the core-config
step when no IANA zone could be detected from the browser or the location
detect API.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:26:50 +03:00
renovate[bot] 50e34015b3 Update tsparticles to v4.0.4 (#52152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:23:24 +03:00
renovate[bot] c1c926c631 Update tsparticles to v4.0.3 (#52148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:11:18 +03:00
renovate[bot] c41afac57c Update dependency typescript-eslint to v8.59.4 (#52147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:10:58 +03:00
Aidan Timson 8856c26929 Add quick links to area page, add area query param support (#52133)
* Add quick links to area page

* Typing

* Add query param support for areas

* hint for the rest

* Add support for helpers

* Add counts
2026-05-21 18:59:20 +02:00
Wendelin 4a0fe3190c Backups: Migrate md-list to new list-base (#52136)
Migrate md-list to new list-base
2026-05-21 18:57:24 +02:00
karwosts 08f7e97462 Use display precision in statistic card (#52138) 2026-05-21 18:53:04 +02:00
Joakim Plate a5791c8c08 Switch to power-standby for media player (#52127) 2026-05-21 18:53:01 +02:00
Wendelin 6a98a74c58 Fix trigger time margin bottom (#52144)
Fix trigger time margin
2026-05-21 18:39:35 +02:00
renovate[bot] c1df3bc38e Update Node.js to v24.16.0 (#52140) 2026-05-21 17:03:13 +02:00
Wendelin 58d4edaa63 Respect via device in device picker, device list (#52131)
* Respect via device in device picker

* Add context to device data table too
2026-05-21 16:01:43 +02:00
Wendelin 176841e647 Automation triggers - auto IDs (#52129)
* Add auto trigger ids

* improve

* Fix paste with no trigger id

* Fix trigger-id-chip and add jsdocs

* review
2026-05-21 16:52:03 +03:00
renovate[bot] 0759e82b47 Update dependency date-fns to v4.2.1 (#52135)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 16:44:50 +03:00
karwosts 7c6609aee7 Make external statistic card unclickable (#52139) 2026-05-21 16:07:43 +03:00
Paul Bottein 7048c5f3d2 Add entity-first card picker for dashboard (#51651)
Co-authored-by: Wendelin <w@pe8.at>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-21 15:01:10 +02:00
Aidan Timson 9ed47be6c3 Add to for devices page, merge 3 cards into 1 related card (#52119)
* Add to for devices page

* Rename and reuse original dialogs, drop popover

* Reduce

* Lazy context

* Direct access lazy context

* Default width

* Merge automations and scripts cards

* Format

* Loading state

* Rename key

* Tooltip and move key

* Copy icons used in more info

* Sort

* Merge scenes into one "Related" card

* Adjust

* Fix no labs

* Use same wording for device actions

* Cleanup

* Comments for removal

* Cleanup

* Type check

* Template literals

* Add padding
2026-05-21 15:35:14 +03:00
Aidan Timson 128f4526e3 Fix no entities message in area page, improve message (#52130) 2026-05-21 15:23:26 +03:00
Wendelin 3f1b7ce391 Add automation item comments (#52090)
* Add automation comments

* Line wrap

* Review

* Add truncateWithEllipsis with tests

* Review
2026-05-21 13:50:51 +03:00
renovate[bot] 4073b4e1f5 Update dependency date-fns to v4.2.0 (#52132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 13:46:57 +03:00
Paul Bottein 86a24d1532 Add temperature and precipitation forecast card features (#51866)
* Rework weather forecast card features

* Add show labels option

* Some fixes

* Fixes and cleaning

* Update palette

* Add reference floor to precipitation bar scale

Light drizzle no longer fills the bar when it's also the period max.
Observed values above the floor still drive the scale (storms read full).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Feedbacks

* Use weather unit

* Force celcius for gradient

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:42:26 +03:00
Wendelin 46bab5bb01 Automation row target count (#52118)
* Add automation row target count

* fix types
2026-05-20 16:23:40 +03:00
iluvdata e8f486af0a Allow integrations to specify the "domain" of the entity that is rendered in previews (#51829)
* Allow previews to use a domain

* Allow previews to specify preview entity domain

* Allow repair_flow to use previews

* Pull recent changes

* Add domain to previews for TemplatePreview
2026-05-20 12:30:03 +03:00
AlCalzone 211579eade Add Z-Wave credential mangement (#51591)
* first rough draft of Z-Wave credential mangement

* separate user and credentials, error handling, dialog tweaks

* align with upstream API changes, improve error handling

* align more with Matter, use lock entity for services

* remove get_credential_status service

* address review feedback, clarify user types

* user_index -> user_id, fix some pending states

* address review feedback

* clean up unused code, strongly type credential types

* Clear -> Delete, drop icons

* Simplify flow to 1 PIN/Password credential per user

* cleanup, comments, etc.

* address review feedback

* do not show existing credential data

* fix lint errors after branch update

* ignore non-enterable credential types when editing user
2026-05-20 12:05:13 +03:00
markvp f6458925c9 Add hidden device firmware column (#52117) 2026-05-20 08:35:32 +00:00
karwosts ae5e35e7ed Include low battery binary_sensors in low battery count (#52115)
Include low binary battery sensors in low battery count
2026-05-20 08:35:11 +03:00
karwosts 8c1727859a Restore battery chips in Home areas strategy (#52114) 2026-05-20 08:27:12 +03:00
Wendelin 287562221f Remove live condition test tooltip (#52103)
* Remove live condition test hover

* Update src/components/automation/ha-automation-row-live-test.ts

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

* Review

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-20 08:21:45 +03:00
Aidan Timson 2593dfed8d Type assertion and signature improvements for hui changed handlers (#52109)
* Type assertion and signature improvements

* Improve
2026-05-19 20:06:20 +02:00
pcan08 2d92f1fb3b Forget filter from url: remaining pages (#52061)
* refactor: use separate storage and display filters in backup page

Apply the two-lists pattern in backup page: _filters (@state, display only) +
_storageFilters (@storage sessionStorage, state: false). _storageFilters
is only updated when not in URL mode (_fromUrl flag). Init moved from
connectedCallback to willUpdate(!hasUpdated).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: use separate storage and display filters in scenes page

Apply the two-lists pattern in scenes page: _filters (@state, display only) +
_storageFilters (@storage sessionStorage, state: false, with
serializer/deserializer). _storageFilters is only updated when not in
URL mode (_fromUrl flag). Init moved from firstUpdated to
willUpdate(!hasUpdated). The existing updated() hook already calls
_applyFilters() when _entityReg changes, covering the reconnect case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: use separate storage and display filters in automations page

Apply the two-lists pattern in automations page: _filters (@state, display only) +
_storageFilters (@storage sessionStorage, state: false, with
serializer/deserializer). _storageFilters is only updated when not in
URL mode (_fromUrl flag). _fromUrl is set before the await in the async
_filterBlueprint() to prevent any user change during the fetch from
persisting. Init moved from firstUpdated to willUpdate(!hasUpdated).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: use separate storage and display filters in scripts page

Apply the two-lists pattern in scripts page: _filters (@state, display only) +
_storageFilters (@storage sessionStorage, state: false, with
serializer/deserializer). _storageFilters is only updated when not in
URL mode (_fromUrl flag). _fromUrl is set before the await in the async
_filterBlueprint() to prevent any user change during the fetch from
persisting. Init moved from firstUpdated to willUpdate(!hasUpdated).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: don't mix URL filters with storage filters in automation,script and scene pages

When URL params are present, _filters starts empty so URL methods build
from scratch. Previously, _filters was pre-populated from _storageFilters
and the spread in _filterLabel()/_filterBlueprint() would merge storage
filters into the URL-injected ones.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update src/panels/config/backup/ha-config-backup-backups.ts

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-05-19 17:47:47 +00:00
pcan08 8cff4c6bd2 Helpers page: forget filter from url (#51989)
* fix(helpers): clear URL-injected filters on leaving helpers dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(helpers): restore previous filters after URL-injected navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: use separate storage and display filters

Apply the same pattern as devices and entities pages: split _filters into
a display-only @state and a _storageFilters persisted to sessionStorage.
_storageFilters is only updated when not in URL mode (_fromUrl flag), so
URL-injected filters never persist to storage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: reapply filters when helper entities load on reconnect

_applyFilters() was never called when _filters was restored from
sessionStorage, leaving _filteredHelperEntityIds undefined and the
table appearing empty. Call it whenever _helperEntities updates and
active filters are present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:43:31 +02:00
renovate[bot] 5aa8455861 Update tsparticles to v4.0.2 (#52110) 2026-05-19 14:08:32 +00:00
renovate[bot] 4d142734d8 Migrate Renovate config (#52105) 2026-05-19 14:07:06 +00:00
Aidan Timson eaecc76f36 Add to automation/script with triggers/conditions/actions (#51871)
* Setup default add to actions

* Setup default add to actions

* Move event into external only

* Split into sections

* Padding

* Refactor to single type and adapt app interface to frontend style and vice versa

* Refactor to single type and adapt app interface to frontend style and vice versa

* Condition action and navigation actions

* Open dialogs with trigger, condition, action dialogs

* Add divider before add to

* Move add to to the top

* Action

* Triggers and conditions labs feature check

* Suggestion

* Keep query state

* Change to automation_trigger

* Use typed key instead of finding with icon

* Apply suggestions from code review

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

* Finish

* Reset state

* Fix navigation resets

* stated

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

* Split

* Add import, sort imports

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-05-19 14:57:44 +02:00
Aidan Timson 7dc0033c03 Improve typing on value-changed handlers in card features and state controls (#52107) 2026-05-19 14:28:52 +02:00
Paul Bottein 601e6d0542 Distinguish unknown from unavailable entity state (#52089) 2026-05-19 15:24:50 +03:00
Aidan Timson c7ca3dd837 Typing assertion and generic improvements on area controls and conditions (#52106) 2026-05-19 15:11:31 +03:00
renovate[bot] f75a376add Update dependency lint-staged to v17.0.5 (#52104) 2026-05-19 12:54:34 +01:00
Aidan Timson a541204ffb Match python version with core version (#52102) 2026-05-19 13:27:31 +02:00
Aidan Timson cbbce90eae Remove YARN_VERSION from netlify.toml (inherit packageManager) (#52101) 2026-05-19 13:26:33 +02:00
Wendelin 950de204aa Restyle and improve app info (#52100) 2026-05-19 09:37:38 +01:00
Jan-Philipp Benecke 91b6a4c4b6 Migrate energy sources table and drop mwc data table dependency (#52097)
* Migrate energy sources table and drop mwc data table dependency

* Address review comments

* Address review comments
2026-05-19 09:58:18 +03:00
karwosts 643cc4ca7d Make energy electric sources nameable (#52051)
Make electric sources nameable

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-19 06:37:49 +00:00
renovate[bot] 9ef71e6cf4 Update tsparticles to v4.0.1 (#52095)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 07:18:29 +02:00
renovate[bot] bface72af7 Lock file maintenance (#52096)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 07:18:12 +02:00
Paul Bottein 90028b2e22 Clarify cleaning order hint in vacuum more info (#52087) 2026-05-18 22:29:36 +02:00
Ben Hamilton (Ben Gertzfield) 914c48abd5 Allow media player source card feature when list is empty (#52094) 2026-05-18 19:05:12 +00:00
renovate[bot] 79c082acde Update dependency eslint to v10.4.0 (#52093)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 20:36:03 +02:00
312 changed files with 16454 additions and 6119 deletions
+3 -3
View File
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
+1 -1
View File
@@ -1 +1 @@
24.15.0
24.16.0
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.14.1.cjs
yarnPath: .yarn/releases/yarn-4.15.0.cjs
@@ -75,7 +75,17 @@ export class DemoAutomationDescribeCondition extends LitElement {
<div class="condition">
<span>
${this._condition
? describeCondition(this._condition, this.hass, [])
? describeCondition(
this._condition,
this.hass.localize,
this.hass.locale,
[],
this.hass.states,
this.hass.entities,
this.hass.config,
this.hass.formatEntityState,
this.hass.formatEntityAttributeValue
)
: "<invalid YAML>"}
</span>
<ha-yaml-editor
@@ -88,7 +98,19 @@ export class DemoAutomationDescribeCondition extends LitElement {
${conditions.map(
(conf) => html`
<div class="condition">
<span>${describeCondition(conf as any, this.hass, [])}</span>
<span
>${describeCondition(
conf as any,
this.hass.localize,
this.hass.locale,
[],
this.hass.states,
this.hass.entities,
this.hass.config,
this.hass.formatEntityState,
this.hass.formatEntityAttributeValue
)}</span
>
<pre>${dump(conf)}</pre>
</div>
`
@@ -99,7 +99,17 @@ export class DemoAutomationDescribeTrigger extends LitElement {
<div class="trigger">
<span>
${this._trigger
? describeTrigger(this._trigger, this.hass, [])
? describeTrigger(
this._trigger,
this.hass.localize,
this.hass.locale,
[],
this.hass.states,
this.hass.entities,
this.hass.config,
this.hass.formatEntityState,
this.hass.formatEntityAttributeValue
)
: "<invalid YAML>"}
</span>
<ha-yaml-editor
@@ -111,7 +121,19 @@ export class DemoAutomationDescribeTrigger extends LitElement {
${triggers.map(
(conf) => html`
<div class="trigger">
<span>${describeTrigger(conf as any, this.hass, [])}</span>
<span
>${describeTrigger(
conf as any,
this.hass.localize,
this.hass.locale,
[],
this.hass.states,
this.hass.entities,
this.hass.config,
this.hass.formatEntityState,
this.hass.formatEntityAttributeValue
)}</span
>
<pre>${dump(conf)}</pre>
</div>
`
-1
View File
@@ -1,3 +1,2 @@
[build.environment]
YARN_VERSION = "1.22.11"
NODE_OPTIONS = "--max_old_space_size=6144"
+27 -28
View File
@@ -38,31 +38,30 @@
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.4.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.5",
"@formatjs/intl-displaynames": "7.3.7",
"@formatjs/intl-durationformat": "0.10.11",
"@formatjs/intl-getcanonicallocales": "3.2.8",
"@formatjs/intl-listformat": "8.3.7",
"@formatjs/intl-locale": "5.3.7",
"@formatjs/intl-numberformat": "9.3.8",
"@formatjs/intl-pluralrules": "6.3.7",
"@formatjs/intl-relativetimeformat": "12.3.7",
"@formatjs/intl-datetimeformat": "7.4.6",
"@formatjs/intl-displaynames": "7.3.8",
"@formatjs/intl-durationformat": "0.10.12",
"@formatjs/intl-getcanonicallocales": "3.2.9",
"@formatjs/intl-listformat": "8.3.8",
"@formatjs/intl-locale": "5.3.8",
"@formatjs/intl-numberformat": "9.3.9",
"@formatjs/intl-pluralrules": "6.3.8",
"@formatjs/intl-relativetimeformat": "12.3.8",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.3.1-ha.3",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
"@material/mwc-base": "0.27.0",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
@@ -75,8 +74,8 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.21",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.0.0",
"@tsparticles/preset-links": "4.0.0",
"@tsparticles/engine": "4.0.5",
"@tsparticles/preset-links": "4.0.5",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -87,7 +86,7 @@
"core-js": "3.49.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.1.0",
"date-fns": "4.3.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
@@ -98,8 +97,8 @@
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.6",
"idb-keyval": "6.2.4",
"intl-messageformat": "11.2.7",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -107,7 +106,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.3",
"marked": "18.0.4",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -119,7 +118,7 @@
"sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"tinykeys": "4.0.0",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
@@ -142,7 +141,7 @@
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.11",
"@rspack/core": "2.0.3",
"@rspack/core": "2.0.4",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -161,12 +160,12 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.6",
"@vitest/coverage-v8": "4.1.7",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.3.0",
"eslint": "10.4.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
@@ -176,7 +175,7 @@
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.5",
"generate-license-file": "4.1.1",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.6.0",
"gulp": "5.0.1",
@@ -188,7 +187,7 @@
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "4.4.2",
"lint-staged": "17.0.4",
"lint-staged": "17.0.5",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -202,9 +201,9 @@
"terser-webpack-plugin": "5.6.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.3",
"typescript-eslint": "8.59.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.6",
"vitest": "4.1.7",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -220,8 +219,8 @@
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.14.1",
"packageManager": "yarn@4.15.0",
"volta": {
"node": "24.15.0"
"node": "24.16.0"
}
}
+39
View File
@@ -18,7 +18,46 @@
"enabled": true,
"schedule": ["on the 19th day of the month before 4am"]
},
"customDatasources": {
"ha-core-python": {
"defaultRegistryUrlTemplate": "https://raw.githubusercontent.com/home-assistant/core/dev/.python-version",
"format": "plain"
}
},
"customManagers": [
{
"description": "Keep PYTHON_VERSION in sync with home-assistant/core (patch + minor)",
"customType": "regex",
"managerFilePatterns": ["/^\\.github/workflows/[^/]+\\.ya?ml$/"],
"matchStrings": ["PYTHON_VERSION: \"(?<currentValue>[^\"]+)\""],
"depNameTemplate": "python",
"datasourceTemplate": "custom.ha-core-python",
"versioningTemplate": "python"
},
{
"description": "Keep devcontainer image and requires-python in sync with home-assistant/core (minor only)",
"customType": "regex",
"managerFilePatterns": [
"/^\\.devcontainer/Dockerfile$/",
"/^pyproject\\.toml$/"
],
"matchStrings": [
"devcontainers/python:(?<currentValue>[\\d.]+)",
"requires-python = \">=(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "python",
"datasourceTemplate": "custom.ha-core-python",
"versioningTemplate": "python",
"extractVersionTemplate": "^(?<version>\\d+\\.\\d+)"
}
],
"packageRules": [
{
"description": "Group all Python version updates from home-assistant/core",
"matchDepNames": ["python"],
"matchDatasources": ["custom.ha-core-python"],
"groupName": "Python version"
},
{
"description": "MDC packages are pinned to the same version as MWC",
"extends": ["monorepo:material-components-web"],
+1 -1
View File
@@ -3,7 +3,7 @@
* @param arr - The array to get combinations of
* @returns A multidimensional array of all possible combinations
*/
export function getAllCombinations<T>(arr: T[]) {
export function getAllCombinations<T>(arr: readonly T[]): T[][] {
return arr.reduce<T[][]>(
(combinations, element) =>
combinations.concat(
+9
View File
@@ -114,6 +114,15 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
export const UNIT_C = "°C";
export const UNIT_F = "°F";
/** Length units. */
export const UNIT_IN = "in";
export const UNIT_KM = "km";
export const UNIT_MM = "mm";
/** Pressure units. */
export const UNIT_HPA = "hPa";
export const UNIT_INHG = "inHg";
/** Entity ID of the default view. */
export const DEFAULT_VIEW_ENTITY_ID = "group.default_view";
+15 -1
View File
@@ -1,6 +1,20 @@
import timezones from "google-timezones-json";
import { TimeZone } from "../../data/translation";
const RESOLVED_TIME_ZONE = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
// Some environments (e.g. Android emulator) return a UTC offset like "+00:00"
// instead of an IANA zone name. Only accept values that are known IANA zones,
// matching the list used by ha-timezone-picker.
const RESOLVED_TIME_ZONE =
RESOLVED_RAW &&
(RESOLVED_RAW === "UTC" ||
RESOLVED_RAW === "Etc/UTC" ||
RESOLVED_RAW in timezones)
? RESOLVED_RAW
: undefined;
export const HAS_RESOLVED_IANA_TIME_ZONE = RESOLVED_TIME_ZONE !== undefined;
// Browser time zone can be determined from Intl, with fallback to UTC for polyfill or no support.
export const LOCAL_TIME_ZONE = RESOLVED_TIME_ZONE ?? "UTC";
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isUnavailableState } from "../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
interface EntityUnitStubConfig {
@@ -21,32 +21,24 @@ export const computeEntityUnitDisplay = (
stateObj: HassEntity | undefined,
config: EntityUnitStubConfig
): string => {
let unit;
if (
stateObj &&
!isUnavailableState(stateObj.state) &&
(config.attribute || stateObj.attributes.device_class !== "duration")
!stateObj ||
stateObj.state === UNAVAILABLE ||
stateObj.state === UNKNOWN ||
(!config.attribute && stateObj.attributes.device_class === "duration")
) {
// check for an explicitly defined unit in config
unit = config.unit;
if (!unit) {
if (!config.attribute) {
// use entity's unit_of_measurement
const stateParts = hass.formatEntityStateToParts(stateObj);
unit = stateParts.find((part) => part.type === "unit")?.value;
} else {
// use attribute's unit if available
const attrParts = hass.formatEntityAttributeValueToParts(
stateObj,
config.attribute
);
unit = attrParts.find((part) => part.type === "unit")?.value;
}
}
return unit ?? "";
return "";
}
return "";
// check for an explicitly defined unit in config
if (config.unit) {
return config.unit;
}
// otherwise derive from the entity's state or attribute
const parts = config.attribute
? hass.formatEntityAttributeValueToParts(stateObj, config.attribute)
: hass.formatEntityStateToParts(stateObj);
return parts.find((part) => part.type === "unit")?.value ?? "";
};
+2 -2
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE_STATES } from "../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { stringCompare } from "../string/compare";
import { computeDomain } from "./compute_domain";
@@ -253,7 +253,7 @@ export const getStatesDomain = (
if (!attribute) {
// All entities can have unavailable states
result.push(...UNAVAILABLE_STATES);
result.push(UNAVAILABLE, UNKNOWN);
}
if (!attribute && domain in FIXED_DOMAIN_STATES) {
+11 -5
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { computeStateDomain } from "./compute_state_domain";
@@ -8,14 +8,20 @@ export const computeGroupEntitiesState = (states: HassEntity[]): string => {
return UNAVAILABLE;
}
const validState = states.some(
(stateObj) => !isUnavailableState(stateObj.state)
const allUnavailable = states.every(
(stateObj) => stateObj.state === UNAVAILABLE
);
if (!validState) {
if (allUnavailable) {
return UNAVAILABLE;
}
const hasValidState = states.some(
(stateObj) => stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
);
if (!hasValidState) {
return UNKNOWN;
}
// Use the first state to determine the domain
// This assumes all states in the group have the same domain
const domain = computeStateDomain(states[0]);
+2 -2
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isUnavailableState, OFF, UNAVAILABLE } from "../../data/entity/entity";
import { OFF, UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { computeDomain } from "./compute_domain";
export function stateActive(stateObj: HassEntity, state?: string): boolean {
@@ -19,7 +19,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
return compareState !== UNAVAILABLE;
}
if (isUnavailableState(compareState)) {
if (compareState === UNAVAILABLE || compareState === UNKNOWN) {
return false;
}
@@ -0,0 +1,17 @@
/**
* @summary Truncates a string to `maxLength`, appending `ellipsis` only when it actually shortens the result.
* @param text The input string.
* @param maxLength Maximum length of the prefix kept before the ellipsis.
* @param ellipsis Suffix appended when truncation occurs.
* @returns `text` unchanged when its length is `<= maxLength + ellipsis.length`, otherwise `text.substring(0, maxLength) + ellipsis`.
*/
export const truncateWithEllipsis = (
text: string,
maxLength: number,
ellipsis = "..."
): string => {
if (text.length <= maxLength + ellipsis.length) {
return text;
}
return `${text.substring(0, maxLength)}${ellipsis}`;
};
@@ -1,6 +1,5 @@
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-tooltip";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
@@ -9,11 +8,10 @@ export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
*
* @summary
* Small status indicator dot used in automation/condition rows to surface the
* live evaluation result. Renders an optional tooltip with details on hover.
* live evaluation result.
*
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
* @attr {string} label - Accessible label announced by assistive technology.
* @attr {string} message - Optional tooltip body shown on hover/focus.
*/
@customElement("ha-automation-row-live-test")
export class HaAutomationRowLiveTest extends LitElement {
@@ -21,8 +19,6 @@ export class HaAutomationRowLiveTest extends LitElement {
@property() public label = "";
@property() public message?: string;
protected render() {
return html`
<div
@@ -31,9 +27,6 @@ export class HaAutomationRowLiveTest extends LitElement {
tabindex="0"
aria-label=${this.label}
></div>
${this.message
? html`<ha-tooltip for="indicator">${this.message}</ha-tooltip>`
: nothing}
`;
}
@@ -56,31 +49,15 @@ export class HaAutomationRowLiveTest extends LitElement {
background-color: var(--ha-color-fill-success-loud-resting);
border-color: var(--ha-color-fill-success-loud-resting);
}
:host([state="pass"]) #indicator:hover {
background-color: var(--ha-color-fill-success-loud-hover);
border-color: var(--ha-color-fill-success-loud-hover);
}
:host([state="fail"]) #indicator {
border-color: var(--ha-color-fill-warning-loud-resting);
}
:host([state="fail"]) #indicator:hover {
background-color: var(--ha-color-fill-warning-loud-hover);
border-color: var(--ha-color-fill-warning-loud-hover);
}
:host([state="invalid"]) #indicator {
border-color: var(--ha-color-fill-danger-loud-resting);
}
:host([state="invalid"]) #indicator:hover {
background-color: var(--ha-color-fill-danger-loud-hover);
border-color: var(--ha-color-fill-danger-loud-hover);
}
:host([state="unknown"]) #indicator {
border-color: var(--ha-color-fill-neutral-loud-resting);
}
:host([state="unknown"]) #indicator:hover {
background-color: var(--ha-color-fill-neutral-loud-hover);
border-color: var(--ha-color-fill-neutral-loud-hover);
}
`;
}
@@ -128,7 +128,9 @@ export class HaAutomationRow extends LitElement {
}
.row {
display: flex;
padding: 0 0 0 var(--ha-space-3);
padding-left: var(--ha-space-3);
padding-inline-start: var(--ha-space-3);
padding-inline-end: initial;
min-height: 48px;
align-items: flex-start;
cursor: pointer;
@@ -144,6 +146,8 @@ export class HaAutomationRow extends LitElement {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--ha-color-on-neutral-quiet);
margin-left: calc(var(--ha-space-2) * -1);
margin-inline-start: calc(var(--ha-space-2) * -1);
margin-inline-end: initial;
}
:host([building-block]) .leading-icon-wrapper {
background-color: var(--ha-color-fill-neutral-loud-resting);
@@ -187,7 +191,6 @@ export class HaAutomationRow extends LitElement {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
margin: 0 var(--ha-space-3);
}
::slotted([slot="header"]) {
overflow-wrap: anywhere;
+1 -1
View File
@@ -116,7 +116,7 @@ export class HaProgressButton extends LitElement {
visibility: hidden;
}
ha-svg-icon {
:host([appearance="brand"]) ha-svg-icon {
color: var(--white-color);
}
`;
@@ -1,10 +1,12 @@
import { consume } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
@@ -12,7 +14,7 @@ import {
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerValueRenderer } from "../ha-picker-field";
@@ -46,13 +48,14 @@ export abstract class HaDeviceAutomationPicker<
}
private _localizeDeviceAutomation: (
hass: HomeAssistant,
localize: LocalizeFunc,
states: HassEntities,
entityRegistry: EntityRegistryEntry[],
automation: T
) => string;
private _fetchDeviceAutomations: (
hass: HomeAssistant,
callWS: CallWS,
deviceId: string
) => Promise<T[]>;
@@ -127,7 +130,8 @@ export abstract class HaDeviceAutomationPicker<
const automationListItems = automations.map((automation, idx) => {
const primary = this._localizeDeviceAutomation(
this.hass,
this.hass.localize,
this.hass.states,
this._entityReg,
automation
);
@@ -162,7 +166,12 @@ export abstract class HaDeviceAutomationPicker<
);
const text = automation
? this._localizeDeviceAutomation(this.hass, this._entityReg, automation)
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this._entityReg,
automation
)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
: value;
@@ -172,9 +181,9 @@ export abstract class HaDeviceAutomationPicker<
private async _updateDeviceInfo() {
this._automations = this.deviceId
? (await this._fetchDeviceAutomations(this.hass, this.deviceId)).sort(
sortDeviceAutomations
)
? (
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
).sort(sortDeviceAutomations)
: // No device, clear the list of automations
[];
+3 -6
View File
@@ -6,11 +6,7 @@ import { customElement, property, state } from "lit/decorators";
import { STATES_OFF } from "../../common/const";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import {
UNAVAILABLE,
UNKNOWN,
isUnavailableState,
} from "../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { forwardHaptic } from "../../data/haptics";
import type { HomeAssistant } from "../../types";
import "../ha-formfield";
@@ -20,7 +16,8 @@ import "../ha-switch";
const isOn = (stateObj?: HassEntity) =>
stateObj !== undefined &&
!STATES_OFF.includes(stateObj.state) &&
!isUnavailableState(stateObj.state);
stateObj.state !== UNAVAILABLE &&
stateObj.state !== UNKNOWN;
/**
* @element ha-entity-toggle
@@ -9,7 +9,7 @@ import secondsToDuration from "../../common/datetime/seconds_to_duration";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import { timerTimeRemaining } from "../../data/timer";
import type { HomeAssistant } from "../../types";
@@ -170,7 +170,8 @@ export class HaStateLabelBadge extends LitElement {
}
// eslint-disable-next-line: disable=no-fallthrough
default:
return isUnavailableState(entityState.state)
return entityState.state === UNAVAILABLE ||
entityState.state === UNKNOWN
? "—"
: this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "value"
@@ -209,7 +210,7 @@ export class HaStateLabelBadge extends LitElement {
_timerTimeRemaining = 0
) {
// For unavailable states or certain domains, use a special translation that is truncated to fit within the badge label
if (isUnavailableState(entityState.state)) {
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
return this.hass!.localize(`state_badge.default.${entityState.state}`);
}
const domainStateKey = getTruncatedKey(domain, entityState.state);
+3 -3
View File
@@ -29,7 +29,7 @@ export interface AreaControlPickerItem extends PickerComboBoxItem {
deviceClass?: string;
}
const AREA_CONTROL_DOMAINS: readonly AreaControlDomain[] = [
const AREA_CONTROL_DOMAINS = [
"light",
"fan",
"switch",
@@ -43,7 +43,7 @@ const AREA_CONTROL_DOMAINS: readonly AreaControlDomain[] = [
"cover-door",
"cover-window",
"cover-damper",
] as const;
] as const satisfies readonly AreaControlDomain[];
@customElement("ha-area-controls-picker")
export class HaAreaControlsPicker extends LitElement {
@@ -130,7 +130,7 @@ export class HaAreaControlsPicker extends LitElement {
(excludeValues !== undefined && excludeValues.includes(id));
const controlEntities = getAreaControlEntities(
AREA_CONTROL_DOMAINS as unknown as AreaControlDomain[],
AREA_CONTROL_DOMAINS,
areaId,
excludeEntities,
this.hass
+9 -4
View File
@@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { ClimateEntity } from "../data/climate";
import { CLIMATE_PRESET_NONE } from "../data/climate";
import { isUnavailableState, OFF } from "../data/entity/entity";
import { OFF, UNAVAILABLE, UNKNOWN } from "../data/entity/entity";
import type { HomeAssistant } from "../types";
@customElement("ha-climate-state")
@@ -14,9 +14,11 @@ class HaClimateState extends LitElement {
protected render(): TemplateResult {
const currentStatus = this._computeCurrentStatus();
const noValue =
this.stateObj.state === UNAVAILABLE || this.stateObj.state === UNKNOWN;
return html`<div class="target">
${!isUnavailableState(this.stateObj.state)
${!noValue
? html`<span class="state-label">
${this._localizeState()}
${this.stateObj.attributes.preset_mode &&
@@ -32,7 +34,7 @@ class HaClimateState extends LitElement {
: this._localizeState()}
</div>
${currentStatus && !isUnavailableState(this.stateObj.state)
${currentStatus && !noValue
? html`
<div class="current">
${this.hass.localize("ui.card.climate.currently")}:
@@ -119,7 +121,10 @@ class HaClimateState extends LitElement {
}
private _localizeState(): string {
if (isUnavailableState(this.stateObj.state)) {
if (
this.stateObj.state === UNAVAILABLE ||
this.stateObj.state === UNKNOWN
) {
return this.hass.localize(`state.default.${this.stateObj.state}`);
}
+14 -7
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import {
mdiAmpersand,
mdiClockOutline,
@@ -12,11 +13,11 @@ import {
mdiWeatherSunny,
} from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { configContext, connectionContext } from "../data/context";
import { conditionIcon, FALLBACK_DOMAIN_ICONS } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@@ -36,12 +37,18 @@ export const CONDITION_ICONS = {
@customElement("ha-condition-icon")
export class HaConditionIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public condition?: string;
@property() public icon?: string;
@state()
@consume({ context: connectionContext, subscribe: true })
protected _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: configContext, subscribe: true })
protected _config?: ContextType<typeof configContext>;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -51,13 +58,13 @@ export class HaConditionIcon extends LitElement {
return nothing;
}
if (!this.hass) {
if (!this._connection || !this._config) {
return this._renderFallback();
}
const icon = conditionIcon(
this.hass.connection,
this.hass.config,
this._connection.connection,
this._config.config,
this.condition
).then((icn) => {
if (icn) {
+1
View File
@@ -294,6 +294,7 @@ export class HaDrawer extends LitElement {
border-inline-end: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12));
box-sizing: border-box;
transition: width var(--ha-animation-duration-normal) ease;
z-index: 6;
}
.app-content {
+11 -4
View File
@@ -6,11 +6,18 @@ import type { HaIconButton } from "./ha-icon-button";
/**
* Event type for the ha-dropdown component when an item is selected.
* @param T - The type of the value of the selected item.
* @param TValue - The type of the selected item's `value`.
* @param TData - The type of the selected item's `data` when set on `ha-dropdown-item`.
*/
export type HaDropdownSelectEvent<T = string> = CustomEvent<{
item: Omit<HaDropdownItem, "value"> & { value: T };
}>;
export type HaDropdownSelectEvent<TValue = string, TData = undefined> = [
TData,
] extends [undefined]
? CustomEvent<{
item: Omit<HaDropdownItem, "value"> & { value: TValue };
}>
: CustomEvent<{
item: Omit<HaDropdownItem, "value"> & { value: TValue; data: TData };
}>;
/**
* Home Assistant dropdown component
+3
View File
@@ -109,6 +109,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ attribute: "custom-value-label" })
public customValueLabel?: string;
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -271,6 +273,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.selectedSection=${this.selectedSection}
.searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
.noSort=${this.noSort}
></ha-picker-combo-box>
`;
}
+9 -4
View File
@@ -1,7 +1,7 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { isUnavailableState, OFF } from "../data/entity/entity";
import { OFF, UNAVAILABLE, UNKNOWN } from "../data/entity/entity";
import type { HumidifierEntity } from "../data/humidifier";
import type { HomeAssistant } from "../types";
@@ -13,9 +13,11 @@ class HaHumidifierState extends LitElement {
protected render(): TemplateResult {
const currentStatus = this._computeCurrentStatus();
const noValue =
this.stateObj.state === UNAVAILABLE || this.stateObj.state === UNKNOWN;
return html`<div class="target">
${!isUnavailableState(this.stateObj.state)
${!noValue
? html`<span class="state-label">
${this._localizeState()}
${this.stateObj.attributes.mode
@@ -30,7 +32,7 @@ class HaHumidifierState extends LitElement {
: this._localizeState()}
</div>
${currentStatus && !isUnavailableState(this.stateObj.state)
${currentStatus && !noValue
? html`<div class="current">
${this.hass.localize("ui.card.humidifier.currently")}:
<div class="unit">${currentStatus}</div>
@@ -69,7 +71,10 @@ class HaHumidifierState extends LitElement {
}
private _localizeState(): string {
if (isUnavailableState(this.stateObj.state)) {
if (
this.stateObj.state === UNAVAILABLE ||
this.stateObj.state === UNKNOWN
) {
return this.hass.localize(`state.default.${this.stateObj.state}`);
}
+3 -1
View File
@@ -167,6 +167,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public clearable = false;
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@@ -342,7 +344,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _getItems = () => {
let items = [...(this.getItems(this._search, this._selectedSection) || [])];
if (!this.sections?.length) {
if (!this.sections?.length && !this.noSort) {
items = items.sort((entityA, entityB) => {
const sortLabelA =
typeof entityA === "string" ? entityA : entityA.sorting_label;
@@ -2,7 +2,7 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { ColorTempSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-labeled-slider";
@@ -94,10 +94,10 @@ export class HaColorTempSelector extends LitElement {
}
);
private _valueChanged(ev: CustomEvent) {
private _valueChanged(ev: HASSDomEvent<HASSDomEvents["value-changed"]>) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: Number((ev.detail as any).value),
value: Number(ev.detail.value),
});
}
}
@@ -199,6 +199,7 @@ export class HaSelectSelector extends LitElement {
: nothing}
<ha-generic-picker
no-sort
.hass=${this.hass}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -215,6 +216,7 @@ export class HaSelectSelector extends LitElement {
if (this.selector.select?.custom_value) {
return html`
<ha-generic-picker
no-sort
.hass=${this.hass}
.label=${this.label}
.helper=${this.helper}
+1 -1
View File
@@ -837,7 +837,7 @@ export class HaServiceControl extends LitElement {
if (targetDevices.length) {
targetDevices = targetDevices.filter((device) =>
deviceMeetsTargetSelector(
this.hass,
this.hass.states,
Object.values(this.hass.entities),
this.hass.devices[device],
targetSelector
+1
View File
@@ -30,6 +30,7 @@ export class HaSettingsRow extends LitElement {
<slot name="prefix"></slot>
<div
class="body"
part="heading"
?two-line=${!this.threeLine && hasDescription}
?three-line=${this.threeLine}
>
+16 -1
View File
@@ -1,12 +1,13 @@
import "@home-assistant/webawesome/dist/components/textarea/textarea";
import type WaTextarea from "@home-assistant/webawesome/dist/components/textarea/textarea";
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { WaInputMixin, waInputStyles } from "./input/wa-input-mixin";
import { stopPropagation } from "../common/dom/stop_propagation";
import { WaInputMixin, waInputStyles } from "./input/wa-input-mixin";
/**
* Home Assistant textarea component
@@ -84,6 +85,20 @@ export class HaTextArea extends WaInputMixin(LitElement) {
this.removeEventListener("keydown", stopPropagation);
}
protected override async firstUpdated(
changedProperties: PropertyValues<this>
): Promise<void> {
super.firstUpdated(changedProperties);
if (this.autofocus) {
await this._textarea?.updateComplete;
this._textarea?.focus();
}
}
public override focus(options?: FocusOptions): void {
this._textarea?.focus(options);
}
protected render() {
const hasLabelSlot = this.label
? false
+63 -37
View File
@@ -1,13 +1,17 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import type { HaSelectOption, HaSelectSelectEvent } from "./ha-select";
import "./ha-select";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
const DEFAULT_THEME = "default";
const SEARCH_KEYS = [{ name: "primary", weight: 1 }];
@customElement("ha-theme-picker")
export class HaThemePicker extends LitElement {
@property() public value?: string;
@@ -25,52 +29,74 @@ export class HaThemePicker extends LitElement {
@property({ type: Boolean }) public required = false;
@property({ attribute: "no-theme-label" }) public noThemeLabel?: string;
private _getThemeOptions = memoizeOne(
(
themes: Record<string, unknown>,
locale: string,
includeDefault: boolean
): PickerComboBoxItem[] => {
const items: PickerComboBoxItem[] = [];
if (includeDefault) {
items.push({ id: DEFAULT_THEME, primary: "Home Assistant" });
}
const themeNames = Object.keys(themes).sort((a, b) =>
caseInsensitiveStringCompare(a, b, locale)
);
for (const theme of themeNames) {
items.push({ id: theme, primary: theme });
}
return items;
}
);
private _getItems = () =>
this._getThemeOptions(
this.hass?.themes.themes || {},
this.hass?.locale.language || "en",
this.includeDefault
);
private _valueRenderer = (value: string): TemplateResult =>
html`<span slot="headline"
>${this._getItems().find((i) => i.id === value)?.primary ?? value}</span
>`;
protected render(): TemplateResult {
const options: HaSelectOption[] = Object.keys(
this.hass?.themes.themes || {}
).map((theme) => ({
value: theme,
}));
if (this.includeDefault) {
options.unshift({
value: DEFAULT_THEME,
label: "Home Assistant",
});
}
if (!this.required) {
options.unshift({
value: "remove",
label: this.hass!.localize("ui.components.theme-picker.no_theme"),
});
}
return html`
<ha-select
.label=${this.label ||
this.hass!.localize("ui.components.theme-picker.theme")}
.value=${this.value}
<ha-generic-picker
.label=${this.label ??
this.hass?.localize("ui.components.theme-picker.theme") ??
"Theme"}
.placeholder=${this.noThemeLabel ??
this.hass?.localize("ui.components.theme-picker.no_theme")}
.helper=${this.helper}
.required=${this.required}
.value=${this.value}
.valueRenderer=${this._valueRenderer}
.getItems=${this._getItems}
.searchKeys=${SEARCH_KEYS}
.disabled=${this.disabled}
@selected=${this._changed}
.options=${options}
></ha-select>
.required=${this.required}
@value-changed=${this._changed}
popover-placement="bottom"
></ha-generic-picker>
`;
}
static styles = css`
ha-select {
ha-generic-picker {
width: 100%;
display: block;
}
`;
private _changed(ev: HaSelectSelectEvent): void {
if (!this.hass || ev.detail.value === "") {
return;
}
this.value = ev.detail.value === "remove" ? undefined : ev.detail.value;
private _changed(ev: ValueChangedEvent<string | undefined>): void {
ev.stopPropagation();
this.value = ev.detail.value;
fireEvent(this, "value-changed", { value: this.value });
}
}
+14 -4
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import {
mdiAvTimer,
mdiCalendar,
@@ -18,9 +19,10 @@ import {
mdiWebhook,
} from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { configContext, connectionContext } from "../data/context";
import { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import type { HomeAssistant } from "../types";
@@ -56,6 +58,14 @@ export class HaTriggerIcon extends LitElement {
@property() public icon?: string;
@state()
@consume({ context: connectionContext, subscribe: true })
protected _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: configContext, subscribe: true })
protected _config?: ContextType<typeof configContext>;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -65,13 +75,13 @@ export class HaTriggerIcon extends LitElement {
return nothing;
}
if (!this.hass) {
if (!this._connection || !this._config) {
return this._renderFallback();
}
const icon = triggerIcon(
this.hass.connection,
this.hass.config,
this._connection.connection,
this._config.config,
this.trigger
).then((icn) => {
if (icn) {
@@ -17,7 +17,7 @@ import { until } from "lit/directives/until";
import { fireEvent } from "../../common/dom/fire_event";
import { slugify } from "../../common/string/slugify";
import { debounce } from "../../common/util/debounce";
import { isUnavailableState } from "../../data/entity/entity";
import { UNAVAILABLE } from "../../data/entity/entity";
import type {
MediaPickedEvent,
MediaPlayerBrowseAction,
@@ -290,7 +290,7 @@ export class HaMediaPlayerBrowse extends LitElement {
} else if (
err.code === "entity_not_found" &&
this.entityId &&
isUnavailableState(this.hass.states[this.entityId]?.state)
this.hass.states[this.entityId]?.state === UNAVAILABLE
) {
this._setError({
message: this.hass.localize(
@@ -454,7 +454,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
try {
const entries = await extractFromTarget(
this.hass,
this.hass.callWS,
{
[`${this.type}_id`]: [this.itemId],
},
+1
View File
@@ -112,6 +112,7 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
text-align: center;
justify-content: center;
padding: 10px 0;
}
.vertical ::slotted([slot="info"]) {
width: 100%;
+24 -6
View File
@@ -171,16 +171,28 @@ export class HaTracePathDetails extends LitElement {
migrateAutomationTrigger({
...currentDetail,
}) as Trigger,
this.hass,
this._entityReg
this.hass.localize,
this.hass.locale,
this._entityReg,
this.hass.states,
this.hass.entities,
this.hass.config,
this.hass.formatEntityState,
this.hass.formatEntityAttributeValue
)}
</h2>`
: selectedType === "condition"
? html`<h2>
${describeCondition(
currentDetail,
this.hass,
this._entityReg
this.hass.localize,
this.hass.locale,
this._entityReg,
this.hass.states,
this.hass.entities,
this.hass.config,
this.hass.formatEntityState,
this.hass.formatEntityAttributeValue
)}
</h2>`
: selectedType === "action"
@@ -215,8 +227,14 @@ export class HaTracePathDetails extends LitElement {
.includes("condition")
? html`[${describeCondition(
currentDetail,
this.hass,
this._entityReg
this.hass.localize,
this.hass.locale,
this._entityReg,
this.hass.states,
this.hass.entities,
this.hass.config,
this.hass.formatEntityState,
this.hass.formatEntityAttributeValue
)}]<br />`
: nothing}
${this.hass!.localize(
+1 -1
View File
@@ -62,7 +62,7 @@ export const AREA_CONTROLS_BUTTONS: Record<
};
export const getAreaControlEntities = (
controls: AreaControlDomain[],
controls: readonly AreaControlDomain[],
areaId: string,
excludeEntities: string[] | undefined,
hass: HomeAssistant
+14 -5
View File
@@ -1,3 +1,4 @@
import { createContext } from "@lit/context";
import type {
Connection,
HassEntityAttributeBase,
@@ -9,7 +10,7 @@ import type { WeekdayShort } from "../common/datetime/weekday";
import { navigate } from "../common/navigate";
import type { LocalizeKeys } from "../common/translations/localize";
import { createSearchParam } from "../common/url/search-params";
import type { Context, HomeAssistant } from "../types";
import type { CallWS, Context, HomeAssistant } from "../types";
import type { BlueprintInput } from "./blueprint";
import type { ConditionDescription } from "./condition";
import { CONDITION_BUILDING_BLOCKS } from "./condition";
@@ -95,6 +96,7 @@ export interface TriggerList {
export interface BaseTrigger {
alias?: string;
comment?: string;
/** @deprecated Use `trigger` instead */
platform?: string;
trigger: string;
@@ -240,6 +242,7 @@ export type Trigger = LegacyTrigger | TriggerList | PlatformTrigger;
interface BaseCondition {
condition: string;
alias?: string;
comment?: string;
enabled?: boolean;
options?: Record<string, unknown>;
}
@@ -488,12 +491,12 @@ export const migrateAutomationTrigger = (
export const flattenTriggers = (
triggers: undefined | Trigger | Trigger[]
): Trigger[] => {
): Exclude<Trigger, TriggerList>[] => {
if (!triggers) {
return [];
}
const flatTriggers: Trigger[] = [];
const flatTriggers: Exclude<Trigger, TriggerList>[] = [];
ensureArray(triggers).forEach((t) => {
if ("triggers" in t) {
@@ -575,11 +578,11 @@ export const subscribeTrigger = (
});
export const testCondition = (
hass: HomeAssistant,
callWS: CallWS,
condition: Condition | Condition[],
variables?: Record<string, unknown>
) =>
hass.callWS<{ result: boolean }>({
callWS<{ result: boolean }>({
type: "test_condition",
condition,
variables,
@@ -607,6 +610,7 @@ export interface AutomationClipboard {
export interface BaseSidebarConfig {
delete: () => void;
close: (focus?: boolean) => void;
editComment: () => void;
}
export interface TriggerSidebarConfig extends BaseSidebarConfig {
@@ -668,6 +672,7 @@ export interface OptionSidebarConfig extends BaseSidebarConfig {
rename: () => void;
duplicate: () => void;
defaultOption?: boolean;
comment?: string;
}
export interface ScriptFieldSidebarConfig extends BaseSidebarConfig {
@@ -693,3 +698,7 @@ export interface ShowAutomationEditorParams {
data?: Partial<AutomationConfig>;
expanded?: boolean;
}
export const automationConfigContext = createContext<
AutomationConfig | undefined
>("automationConfig");
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -17,6 +17,7 @@ export interface BluetoothDeviceData extends DataTableRowData {
source: string;
time: number;
tx_power: number;
raw: string | null;
}
export interface BluetoothConnectionData extends DataTableRowData {
@@ -58,13 +59,21 @@ export interface BluetoothAllocationsData {
allocated: string[];
}
export type BluetoothScannerMode = "active" | "passive";
export type BluetoothScannerRequestedMode = BluetoothScannerMode | "auto";
export interface BluetoothScannerState {
source: string;
adapter: string;
current_mode: "active" | "passive" | null;
requested_mode: "active" | "passive" | null;
current_mode: BluetoothScannerMode | null;
requested_mode: BluetoothScannerRequestedMode | null;
}
export const isScannerStateMismatch = (state: BluetoothScannerState): boolean =>
state.requested_mode !== "auto" &&
state.current_mode !== state.requested_mode;
export const subscribeBluetoothScannersDetailsUpdates = (
conn: Connection,
store: Store<BluetoothScannersDetails>
+2 -2
View File
@@ -6,7 +6,7 @@ import { getColorByIndex } from "../common/color/colors";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import type { HomeAssistant } from "../types";
import { isUnavailableState } from "./entity/entity";
import { UNAVAILABLE } from "./entity/entity";
import type { EntityRegistryEntry } from "./entity/entity_registry";
export interface Calendar {
@@ -120,7 +120,7 @@ export const getCalendars = (
.filter(
(eid) =>
computeDomain(eid) === "calendar" &&
!isUnavailableState(hass.states[eid].state) &&
hass.states[eid].state !== UNAVAILABLE &&
hass.entities[eid]?.hidden !== true
)
.sort()
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { CallWS } from "../types";
export interface ValidConfig {
valid: true;
@@ -13,10 +13,10 @@ export interface InvalidConfig {
type ValidKeys = "triggers" | "actions" | "conditions";
export const validateConfig = <T extends Partial<Record<ValidKeys, unknown>>>(
hass: HomeAssistant,
callWS: CallWS,
config: T
): Promise<Record<keyof T, ValidConfig | InvalidConfig>> =>
hass.callWS({
callWS({
type: "validate_config",
...config,
});
+4 -1
View File
@@ -5,7 +5,10 @@ export interface DataTableFilter {
export type DataTableFilters = Record<string, DataTableFilter>;
export type DataTableFiltersValue = string[] | { key: string[] } | undefined;
export type DataTableFiltersValue =
| string[]
| Record<"key" | string, string[]>
| undefined;
export type DataTableFiltersValues = Record<string, DataTableFiltersValue>;
+66 -48
View File
@@ -1,17 +1,19 @@
import type { HassEntities } from "home-assistant-js-websocket";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HaFormSchema } from "../../components/ha-form/types";
import type { HomeAssistant } from "../../types";
import type { CallWS } from "../../types";
import type { BaseTrigger } from "../automation";
import { migrateAutomationTrigger } from "../automation";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import {
computeEntityRegistryName,
entityRegistryByEntityId,
entityRegistryById,
} from "../entity/entity_registry";
export interface DeviceAutomation {
alias?: string;
comment?: string;
device_id: string;
domain: string;
entity_id?: string;
@@ -39,49 +41,47 @@ export interface DeviceCapabilities {
extra_fields: HaFormSchema[];
}
export const fetchDeviceActions = (hass: HomeAssistant, deviceId: string) =>
hass.callWS<DeviceAction[]>({
export const fetchDeviceActions = (callWS: CallWS, deviceId: string) =>
callWS<DeviceAction[]>({
type: "device_automation/action/list",
device_id: deviceId,
});
export const fetchDeviceConditions = (hass: HomeAssistant, deviceId: string) =>
hass.callWS<DeviceCondition[]>({
export const fetchDeviceConditions = (callWS: CallWS, deviceId: string) =>
callWS<DeviceCondition[]>({
type: "device_automation/condition/list",
device_id: deviceId,
});
export const fetchDeviceTriggers = (hass: HomeAssistant, deviceId: string) =>
hass
.callWS<DeviceTrigger[]>({
type: "device_automation/trigger/list",
device_id: deviceId,
})
.then((triggers) => migrateAutomationTrigger(triggers) as DeviceTrigger[]);
export const fetchDeviceTriggers = (callWS: CallWS, deviceId: string) =>
callWS<DeviceTrigger[]>({
type: "device_automation/trigger/list",
device_id: deviceId,
}).then((triggers) => migrateAutomationTrigger(triggers) as DeviceTrigger[]);
export const fetchDeviceActionCapabilities = (
hass: HomeAssistant,
callWS: CallWS,
action: DeviceAction
) =>
hass.callWS<DeviceCapabilities>({
callWS<DeviceCapabilities>({
type: "device_automation/action/capabilities",
action,
});
export const fetchDeviceConditionCapabilities = (
hass: HomeAssistant,
callWS: CallWS,
condition: DeviceCondition
) =>
hass.callWS<DeviceCapabilities>({
callWS<DeviceCapabilities>({
type: "device_automation/condition/capabilities",
condition,
});
export const fetchDeviceTriggerCapabilities = (
hass: HomeAssistant,
callWS: CallWS,
trigger: DeviceTrigger
) =>
hass.callWS<DeviceCapabilities>({
callWS<DeviceCapabilities>({
type: "device_automation/trigger/capabilities",
trigger,
});
@@ -184,19 +184,16 @@ const compareEntityIdWithEntityRegId = (
};
const getEntityName = (
hass: HomeAssistant,
localize: LocalizeFunc,
states: HassEntities,
entityRegistry: EntityRegistryEntry[],
entityId: string | undefined
): string => {
if (!entityId) {
return (
"<" +
hass.localize("ui.panel.config.automation.editor.unknown_entity") +
">"
);
return `<${localize("ui.panel.config.automation.editor.unknown_entity")}>`;
}
if (entityId.includes(".")) {
const state = hass.states[entityId];
const state = states[entityId];
if (state) {
return computeStateName(state);
}
@@ -204,26 +201,35 @@ const getEntityName = (
}
const entityReg = entityRegistryById(entityRegistry)[entityId];
if (entityReg) {
return computeEntityRegistryName(hass, entityReg) || entityId;
if (entityReg.name) {
return entityReg.name;
}
const state = states[entityReg.entity_id];
if (state) {
return computeStateName(state);
}
return entityReg.original_name ?? entityId;
}
return (
"<" +
hass.localize("ui.panel.config.automation.editor.unknown_entity") +
">"
);
return `<${localize("ui.panel.config.automation.editor.unknown_entity")}>`;
};
export const localizeDeviceAutomationAction = (
hass: HomeAssistant,
localize: LocalizeFunc,
states: HassEntities,
entityRegistry: EntityRegistryEntry[],
action: DeviceAction
): string =>
hass.localize(
localize(
`component.${action.domain}.device_automation.action_type.${action.type}`,
{
entity_name: getEntityName(hass, entityRegistry, action.entity_id),
entity_name: getEntityName(
localize,
states,
entityRegistry,
action.entity_id
),
subtype: action.subtype
? hass.localize(
? localize(
`component.${action.domain}.device_automation.action_subtype.${action.subtype}`
) || action.subtype
: "",
@@ -231,16 +237,22 @@ export const localizeDeviceAutomationAction = (
) || (action.subtype ? `"${action.subtype}" ${action.type}` : action.type!);
export const localizeDeviceAutomationCondition = (
hass: HomeAssistant,
localize: LocalizeFunc,
states: HassEntities,
entityRegistry: EntityRegistryEntry[],
condition: DeviceCondition
): string =>
hass.localize(
localize(
`component.${condition.domain}.device_automation.condition_type.${condition.type}`,
{
entity_name: getEntityName(hass, entityRegistry, condition.entity_id),
entity_name: getEntityName(
localize,
states,
entityRegistry,
condition.entity_id
),
subtype: condition.subtype
? hass.localize(
? localize(
`component.${condition.domain}.device_automation.condition_subtype.${condition.subtype}`
) || condition.subtype
: "",
@@ -251,16 +263,22 @@ export const localizeDeviceAutomationCondition = (
: condition.type!);
export const localizeDeviceAutomationTrigger = (
hass: HomeAssistant,
localize: LocalizeFunc,
states: HassEntities,
entityRegistry: EntityRegistryEntry[],
trigger: DeviceTrigger
): string =>
hass.localize(
localize(
`component.${trigger.domain}.device_automation.trigger_type.${trigger.type}`,
{
entity_name: getEntityName(hass, entityRegistry, trigger.entity_id),
entity_name: getEntityName(
localize,
states,
entityRegistry,
trigger.entity_id
),
subtype: trigger.subtype
? hass.localize(
? localize(
`component.${trigger.domain}.device_automation.trigger_subtype.${trigger.subtype}`
) || trigger.subtype
: "",
@@ -269,18 +287,18 @@ export const localizeDeviceAutomationTrigger = (
(trigger.subtype ? `"${trigger.subtype}" ${trigger.type}` : trigger.type!);
export const localizeExtraFieldsComputeLabelCallback =
(hass: HomeAssistant, deviceAutomation: DeviceAutomation) =>
(localize: LocalizeFunc, deviceAutomation: DeviceAutomation) =>
// Returns a callback for ha-form to calculate labels per schema object
(schema): string =>
hass.localize(
localize(
`component.${deviceAutomation.domain}.device_automation.extra_fields.${schema.name}`
) || schema.name;
export const localizeExtraFieldsComputeHelperCallback =
(hass: HomeAssistant, deviceAutomation: DeviceAutomation) =>
(localize: LocalizeFunc, deviceAutomation: DeviceAutomation) =>
// Returns a callback for ha-form to calculate helper texts per schema object
(schema): string | undefined =>
hass.localize(
localize(
`component.${deviceAutomation.domain}.device_automation.extra_fields_descriptions.${schema.name}`
);
+70 -3
View File
@@ -2,16 +2,23 @@ import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import type { LocalizeFunc } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../types";
import type { ConfigEntry } from "../config_entries";
import type { HaEntityPickerEntityFilterFunc } from "../entity/entity";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../entity/entity_registry";
import { domainToName } from "../integration";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
type DeviceRegistryEntry,
} from "./device_registry";
export interface DevicePickerItem extends PickerComboBoxItem {
@@ -19,6 +26,46 @@ export interface DevicePickerItem extends PickerComboBoxItem {
domain_name?: string;
}
export interface DeviceAreaLabel {
areaName?: string;
viaDeviceName?: string;
viaDeviceAreaName?: string;
}
export const computeDeviceAreaLabel = (
device: DeviceRegistryEntry,
areas: HomeAssistant["areas"],
devices: HomeAssistant["devices"],
states: HomeAssistant["states"],
localize: LocalizeFunc,
language: HomeAssistant["language"],
translationMetadata: HomeAssistant["translationMetadata"],
viaDeviceEntities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[]
): DeviceAreaLabel => {
const area = getDeviceArea(device, areas);
const viaDevice = device.via_device_id
? devices[device.via_device_id]
: undefined;
const viaDeviceName = viaDevice
? computeDeviceNameDisplay(viaDevice, localize, states, viaDeviceEntities)
: undefined;
const viaDeviceArea = viaDevice ? getDeviceArea(viaDevice, areas) : undefined;
const viaDeviceAreaName = viaDeviceArea
? computeAreaName(viaDeviceArea)
: undefined;
const isRTL = computeRTL(language, translationMetadata.translations);
const areaName = area
? computeAreaName(area)
: viaDeviceAreaName
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
: viaDeviceName || undefined;
return { areaName, viaDeviceName, viaDeviceAreaName };
};
export const deviceComboBoxKeys: FuseWeightedKey[] = [
{
name: "search_labels.deviceName",
@@ -36,6 +83,14 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.domain",
weight: 4,
},
{
name: "search_labels.viaDeviceName",
weight: 3,
},
{
name: "search_labels.viaDeviceArea",
weight: 3,
},
];
export const getDevices = (
@@ -149,9 +204,19 @@ export const getDevices = (
deviceEntityLookup[device.id]
);
const area = getDeviceArea(device, hass.areas);
const areaName = area ? computeAreaName(area) : undefined;
const { areaName, viaDeviceName, viaDeviceAreaName } =
computeDeviceAreaLabel(
device,
hass.areas,
hass.devices,
hass.states,
hass.localize,
hass.language,
hass.translationMetadata,
device.via_device_id
? deviceEntityLookup[device.via_device_id]
: undefined
);
const configEntry = device.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
@@ -174,6 +239,8 @@ export const getDevices = (
areaName: areaName || null,
domain: domain || null,
domainName: domainName || null,
viaDeviceName: viaDeviceName || null,
viaDeviceArea: viaDeviceAreaName || null,
},
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
};
+3
View File
@@ -148,6 +148,7 @@ export interface GridSourceTypeEnergyPreference {
power_config?: PowerConfig;
cost_adjustment_day: number;
name?: string;
}
export interface SolarSourceTypeEnergyPreference {
@@ -156,6 +157,7 @@ export interface SolarSourceTypeEnergyPreference {
stat_energy_from: string;
stat_rate?: string;
config_entry_solar_forecast: string[] | null;
name?: string;
}
export interface BatterySourceTypeEnergyPreference {
@@ -165,6 +167,7 @@ export interface BatterySourceTypeEnergyPreference {
stat_rate?: string; // always available if power_config is set
power_config?: PowerConfig;
stat_soc?: string;
name?: string;
}
export interface GasSourceTypeEnergyPreference {
type: "gas";
-2
View File
@@ -6,10 +6,8 @@ export const UNKNOWN = "unknown";
export const ON = "on";
export const OFF = "off";
export const UNAVAILABLE_STATES = [UNAVAILABLE, UNKNOWN] as const;
export const OFF_STATES = [UNAVAILABLE, UNKNOWN, OFF] as const;
export const isUnavailableState = arrayLiteralIncludes(UNAVAILABLE_STATES);
export const isOffState = arrayLiteralIncludes(OFF_STATES);
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;
+97 -199
View File
@@ -1,11 +1,15 @@
import { atLeastVersion } from "../../common/config/version";
import type { HaFormSchema } from "../../components/ha-form/types";
import type { HomeAssistant, TranslationDict } from "../../types";
import type {
CallWS,
HomeAssistant,
HomeAssistantApi,
TranslationDict,
} from "../../types";
import { supervisorApiCall } from "../supervisor/common";
import type { StoreAddonDetails } from "../supervisor/store";
import type { Supervisor, SupervisorArch } from "../supervisor/supervisor";
import type { HassioResponse } from "./common";
import { extractApiErrorMessage, hassioApiResultExtractor } from "./common";
import { extractApiErrorMessage } from "./common";
export type AddonCapability = Exclude<
keyof TranslationDict["ui"]["panel"]["config"]["apps"]["dashboard"]["capability"],
@@ -143,57 +147,38 @@ export interface HassioAddonSetOptionParams {
}
export const reloadHassioAddons = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/addons/reload",
method: "post",
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/addons/reload`);
await hass.callWS({
type: "supervisor/api",
endpoint: "/addons/reload",
method: "post",
});
};
export const fetchHassioAddonsInfo = async (
hass: HomeAssistant
): Promise<HassioAddonsInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/addons",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioAddonsInfo>>("GET", `hassio/addons`)
);
return hass.callWS({
type: "supervisor/api",
endpoint: "/addons",
method: "get",
});
};
export const fetchHassioAddonInfo = async (
hass: HomeAssistant,
callWS: CallWS,
slug: string
): Promise<HassioAddonDetails> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/info`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioAddonDetails>>(
"GET",
`hassio/addons/${slug}/info`
)
);
return callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/info`,
method: "get",
});
};
export const fetchHassioAddonChangelog = async (
hass: HomeAssistant,
api: HomeAssistantApi,
slug: string
) => hass.callApi<string>("GET", `hassio/addons/${slug}/changelog`);
) => api.callApi<string>("GET", `hassio/addons/${slug}/changelog`);
export const fetchHassioAddonLogs = async (hass: HomeAssistant, slug: string) =>
hass.callApi<string>("GET", `hassio/addons/${slug}/logs`);
@@ -204,119 +189,77 @@ export const fetchHassioAddonDocumentation = async (
) => hass.callApi<string>("GET", `hassio/addons/${slug}/documentation`);
export const setHassioAddonOption = async (
hass: HomeAssistant,
callWS: CallWS,
slug: string,
data: HassioAddonSetOptionParams
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
const response = await hass.callWS<HassioResponse<any>>({
type: "supervisor/api",
endpoint: `/addons/${slug}/options`,
method: "post",
data,
});
const response = await callWS<HassioResponse<any>>({
type: "supervisor/api",
endpoint: `/addons/${slug}/options`,
method: "post",
data,
});
if (response.result === "error") {
throw Error(extractApiErrorMessage(response));
}
return response;
if (response.result === "error") {
throw Error(extractApiErrorMessage(response));
}
return hass.callApi<HassioResponse<any>>(
"POST",
`hassio/addons/${slug}/options`,
data
);
return response;
};
export const validateHassioAddonOption = async (
hass: HomeAssistant,
callWS: CallWS,
slug: string,
data?: any
): Promise<{ message: string; valid: boolean }> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/options/validate`,
method: "post",
data,
});
}
return (
await hass.callApi<HassioResponse<{ message: string; valid: boolean }>>(
"POST",
`hassio/addons/${slug}/options/validate`
)
).data;
return callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/options/validate`,
method: "post",
data,
});
};
export const startHassioAddon = async (hass: HomeAssistant, slug: string) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/start`,
method: "post",
timeout: null,
});
}
return hass.callApi<string>("POST", `hassio/addons/${slug}/start`);
export const startHassioAddon = async (callWS: CallWS, slug: string) => {
return callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/start`,
method: "post",
timeout: null,
});
};
export const stopHassioAddon = async (hass: HomeAssistant, slug: string) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/stop`,
method: "post",
timeout: null,
});
}
return hass.callApi<string>("POST", `hassio/addons/${slug}/stop`);
export const stopHassioAddon = async (callWS: CallWS, slug: string) => {
return callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/stop`,
method: "post",
timeout: null,
});
};
export const setHassioAddonSecurity = async (
hass: HomeAssistant,
callWS: CallWS,
slug: string,
data: HassioAddonSetSecurityParams
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/security`,
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
`hassio/addons/${slug}/security`,
data
);
await callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/security`,
method: "post",
data,
});
};
export const installHassioAddon = async (
hass: HomeAssistant,
callWS: CallWS,
slug: string
): Promise<void> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/install`,
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
`hassio/addons/${slug}/install`
);
await callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/install`,
method: "post",
timeout: null,
});
};
export const updateHassioAddon = async (
@@ -324,74 +267,37 @@ export const updateHassioAddon = async (
slug: string,
backup: boolean
): Promise<void> => {
if (atLeastVersion(hass.config.version, 2025, 2, 0)) {
await hass.callWS({
type: "hassio/update/addon",
addon: slug,
backup: backup,
});
return;
}
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/store/addons/${slug}/update`,
method: "post",
timeout: null,
data: { backup },
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
`hassio/addons/${slug}/update`,
{ backup }
);
await hass.callWS({
type: "hassio/update/addon",
addon: slug,
backup: backup,
});
};
export const restartHassioAddon = async (
hass: HomeAssistant,
callWS: CallWS,
slug: string
): Promise<void> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/restart`,
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
`hassio/addons/${slug}/restart`
);
await callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/restart`,
method: "post",
timeout: null,
});
};
export const uninstallHassioAddon = async (
hass: HomeAssistant,
callWS: CallWS,
slug: string,
removeData: boolean
): Promise<void> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/uninstall`,
method: "post",
timeout: null,
data: { remove_config: removeData },
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
`hassio/addons/${slug}/uninstall`,
{ remove_config: removeData }
);
await callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/uninstall`,
method: "post",
timeout: null,
data: { remove_config: removeData },
});
};
export const fetchAddonInfo = (
@@ -407,21 +313,13 @@ export const fetchAddonInfo = (
);
export const rebuildLocalAddon = async (
hass: HomeAssistant,
callWS: CallWS,
slug: string
): Promise<void> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS<undefined>({
type: "supervisor/api",
endpoint: `/addons/${slug}/rebuild`,
method: "post",
timeout: null,
});
}
return (
await hass.callApi<HassioResponse<void>>(
"POST",
`hassio/addons/${slug}rebuild`
)
).data;
return callWS<undefined>({
type: "supervisor/api",
endpoint: `/addons/${slug}/rebuild`,
method: "post",
timeout: null,
});
};
+7 -17
View File
@@ -1,5 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { CallWS } from "../../types";
export interface HassioResponse<T> {
data: T;
@@ -46,21 +45,12 @@ export const ignoreSupervisorError = (error): boolean => {
};
export const fetchHassioStats = async (
hass: HomeAssistant,
callWS: CallWS,
container: string
): Promise<HassioStats> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/${container}/stats`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioStats>>(
"GET",
`hassio/${container}/stats`
)
);
return callWS({
type: "supervisor/api",
endpoint: `/${container}/stats`,
method: "get",
});
};
+3 -3
View File
@@ -15,7 +15,7 @@ import {
mdiPlaylistMusic,
mdiPlayPause,
mdiPodcast,
mdiPower,
mdiPowerStandby,
mdiPowerOff,
mdiPowerOn,
mdiRepeat,
@@ -295,7 +295,7 @@ export const computeMediaControls = (
return supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_ON)
? [
{
icon: mdiPower,
icon: mdiPowerStandby,
action: "turn_on",
},
]
@@ -316,7 +316,7 @@ export const computeMediaControls = (
if (supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_OFF)) {
buttons.push({
icon: assumedState ? mdiPowerOff : mdiPower,
icon: assumedState ? mdiPowerOff : mdiPowerStandby,
action: "turn_off",
});
}
+6 -1
View File
@@ -7,13 +7,18 @@ export interface GenericPreview {
state: string;
attributes: Record<string, any>;
error?: string;
domain?: string;
}
export const subscribePreviewGeneric = (
hass: HomeAssistant,
domain: string,
flow_id: string,
flow_type: "config_flow" | "options_flow" | "config_subentries_flow",
flow_type:
| "config_flow"
| "options_flow"
| "config_subentries_flow"
| "repair_flow",
user_input: Record<string, any>,
callback: (preview: GenericPreview) => void
): Promise<UnsubscribeFunc> =>
+3
View File
@@ -36,6 +36,7 @@ export const isMaxMode = arrayLiteralIncludes(MODES_MAX);
export const baseActionStruct = object({
alias: optional(string()),
comment: optional(string()),
continue_on_error: optional(boolean()),
enabled: optional(boolean()),
});
@@ -105,6 +106,7 @@ export interface Field {
interface BaseAction {
alias?: string;
comment?: string;
continue_on_error?: boolean;
enabled?: boolean;
}
@@ -195,6 +197,7 @@ export interface ForEachRepeat extends BaseRepeat {
export interface Option {
alias?: string;
comment?: string;
conditions: string | Condition[];
sequence: Action | Action[];
}
+14 -3
View File
@@ -9,8 +9,8 @@ import type { Condition } from "./automation";
import { describeCondition } from "./automation_i18n";
import { localizeDeviceAutomationAction } from "./device/device_automation";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import { domainToName } from "./integration";
import type { DomainManifestLookup } from "./integration";
import { domainToName } from "./integration";
import type {
ActionType,
ActionTypes,
@@ -322,7 +322,17 @@ const tryDescribeAction = <T extends ActionType>(
return hass.localize(
`${actionTranslationBaseKey}.check_condition.description.full`,
{
condition: describeCondition(action as Condition, hass, entityRegistry),
condition: describeCondition(
action as Condition,
hass.localize,
hass.locale,
entityRegistry,
hass.states,
hass.entities,
hass.config,
hass.formatEntityState,
hass.formatEntityAttributeValue
),
}
);
}
@@ -335,7 +345,8 @@ const tryDescribeAction = <T extends ActionType>(
);
}
const localized = localizeDeviceAutomationAction(
hass,
hass.localize,
hass.states,
entityRegistry,
config
);
+5 -5
View File
@@ -641,7 +641,7 @@ export const expandLabelTarget = (
if (
device.labels.includes(labelId) &&
deviceMeetsTargetSelector(
hass,
hass.states,
Object.values(entities),
device,
targetSelector,
@@ -708,7 +708,7 @@ export const expandAreaTarget = (
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass,
hass.states,
Object.values(entities),
device,
targetSelector,
@@ -768,7 +768,7 @@ export const areaMeetsTargetSelector = (
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass,
hass.states,
Object.values(entities),
device,
targetSelector,
@@ -798,7 +798,7 @@ export const areaMeetsTargetSelector = (
};
export const deviceMeetsTargetSelector = (
hass: HomeAssistant,
states: HomeAssistant["states"],
entityRegistry: EntityRegistryDisplayEntry[] | EntityRegistryEntry[],
device: DeviceRegistryEntry,
targetSelector: TargetSelector,
@@ -822,7 +822,7 @@ export const deviceMeetsTargetSelector = (
(reg) => reg.device_id === device.id
);
return entities.some((entity) => {
const entityState = hass.states[entity.entity_id];
const entityState = states[entity.entity_id];
return entityMeetsTargetSelector(
entityState,
targetSelector,
+3 -3
View File
@@ -3,7 +3,7 @@ import { ensureArray } from "../common/array/ensure-array";
import { computeDomain } from "../common/entity/compute_domain";
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { HomeAssistant } from "../types";
import type { CallWS, HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area/area_registry";
import type { FloorComboBoxItem } from "./area_floor_picker";
import type { DevicePickerItem } from "./device/device_picker";
@@ -47,12 +47,12 @@ export interface ExtractFromTargetResultReferenced {
}
export const extractFromTarget = async (
hass: HomeAssistant,
callWS: CallWS,
target: HassServiceTarget,
expandGroup = false,
primaryEntitiesOnly = true
) =>
hass.callWS<ExtractFromTargetResult>({
callWS<ExtractFromTargetResult>({
type: "extract_from_target",
target,
expand_group: expandGroup,
+2 -2
View File
@@ -2,7 +2,7 @@ import { computeDomain } from "../common/entity/compute_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { stringCompare } from "../common/string/compare";
import type { HomeAssistant, ServiceCallResponse } from "../types";
import { isUnavailableState } from "./entity/entity";
import { UNAVAILABLE } from "./entity/entity";
export interface TodoList {
entity_id: string;
@@ -49,7 +49,7 @@ export const getTodoLists = (
.filter(
(entityId) =>
computeDomain(entityId) === "todo" &&
!isUnavailableState(hass.states[entityId].state) &&
hass.states[entityId].state !== UNAVAILABLE &&
(includeHidden || hass.entities[entityId]?.hidden !== true)
)
.map((entityId) => ({
+44
View File
@@ -8,6 +8,7 @@ import type {
Trigger,
TriggerList,
} from "./automation";
import { flattenTriggers } from "./automation";
import type { Selector, TargetSelector } from "./selector";
export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
@@ -56,6 +57,49 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
"triggers" in trigger;
export const getTriggerIds = (triggers: Trigger[]): string[] =>
flattenTriggers(triggers)
.map((trigger) => trigger.id)
.filter((id): id is string => !!id);
export const getNextNumericTriggerId = (triggers: Trigger[]): string => {
let max = 0;
for (const id of getTriggerIds(triggers)) {
const num = Number(id);
if (Number.isInteger(num) && num > max) {
max = num;
}
}
return String(max + 1);
};
const computeUniqueId = (id: string, existing: Set<string>): string => {
if (!existing.has(id)) {
return id;
}
// Split into a base and a trailing integer suffix so we can bump the
// suffix on collision (e.g. "foo2" -> "foo3"); if there's no trailing
// digit we start at 2 ("foo" -> "foo2").
const match = id.match(/^(.*?)(\d+)$/);
let base: string;
let num: number;
if (match) {
base = match[1];
num = Number(match[2]) + 1;
} else {
base = id;
num = 2;
}
while (existing.has(`${base}${num}`)) {
num++;
}
return `${base}${num}`;
};
export const getUniqueTriggerId = (id: string, triggers: Trigger[]): string =>
computeUniqueId(id, new Set(getTriggerIds(triggers)));
export interface TriggerDescription {
target?: TargetSelector["target"];
fields: Record<
+9 -2
View File
@@ -29,6 +29,13 @@ import type {
import type { SVGTemplateResult, TemplateResult } from "lit";
import { css, html, svg } from "lit";
import { styleMap } from "lit/directives/style-map";
import {
UNIT_HPA,
UNIT_IN,
UNIT_INHG,
UNIT_KM,
UNIT_MM,
} from "../common/const";
import { supportsFeature } from "../common/entity/supports-feature";
import { round } from "../common/number/round";
import "../components/ha-svg-icon";
@@ -245,12 +252,12 @@ export const getWeatherUnit = (
case "precipitation":
return (
stateObj.attributes.precipitation_unit ||
(lengthUnit === "km" ? "mm" : "in")
(lengthUnit === UNIT_KM ? UNIT_MM : UNIT_IN)
);
case "pressure":
return (
stateObj.attributes.pressure_unit ||
(lengthUnit === "km" ? "hPa" : "inHg")
(lengthUnit === UNIT_KM ? UNIT_HPA : UNIT_INHG)
);
case "apparent_temperature":
case "dew_point":
+1
View File
@@ -24,6 +24,7 @@ interface TemplatePreviewState {
state: string;
attributes: Record<string, any>;
listeners: TemplateListeners;
domain?: string;
}
interface TemplatePreviewError {
+258
View File
@@ -0,0 +1,258 @@
import type { HomeAssistant } from "../types";
export type ZwaveCredentialType =
| "pin_code"
| "password"
| "rfid_code"
| "ble"
| "nfc"
| "uwb"
| "eye_biometric"
| "face_biometric"
| "finger_biometric"
| "hand_biometric"
| "unspecified_biometric"
| "desfire";
export const ENTERABLE_ZWAVE_CREDENTIAL_TYPES: readonly ZwaveCredentialType[] =
["pin_code", "password"];
// UI surfaces only general + disposable to stay aligned with Matter lock UX.
// Other types (programming, duress, non_access, remote_only, expiring) are
// defined in translations for display in existing-user rows, but are not
// selectable here.
export const SIMPLE_USER_TYPES: readonly string[] = ["general", "disposable"];
// Fallback bounds when a lock advertises an enterable type without
// per-type min/max — values mirror Z-Wave spec defaults.
export const DEFAULT_CREDENTIAL_MIN_LENGTH = 4;
export const DEFAULT_CREDENTIAL_MAX_LENGTH = 10;
export type CredentialErrorCode =
| "required"
| "length"
| "pin_digits_only"
| "";
export const enterableCredentialTypes = (
capabilities: ZwaveCredentialCapabilities
): ZwaveCredentialType[] => {
if (!capabilities.supported_credential_types) {
return [];
}
return ENTERABLE_ZWAVE_CREDENTIAL_TYPES.filter(
(type) => type in capabilities.supported_credential_types
);
};
export const compatibleUserTypes = (
capabilities: ZwaveCredentialCapabilities
): string[] => {
const supported = capabilities.supported_user_types ?? [];
return SIMPLE_USER_TYPES.filter((t) => supported.includes(t));
};
export const canAddZwaveUser = (
capabilities: ZwaveCredentialCapabilities
): boolean =>
enterableCredentialTypes(capabilities).length > 0 &&
compatibleUserTypes(capabilities).length > 0;
export const getCredentialError = (
data: string,
type: ZwaveCredentialType | "",
capability: ZwaveCredentialTypeCapability | undefined
): CredentialErrorCode => {
if (!data) {
return "required";
}
const minLength = capability?.min_length ?? DEFAULT_CREDENTIAL_MIN_LENGTH;
const maxLength = capability?.max_length ?? DEFAULT_CREDENTIAL_MAX_LENGTH;
if (data.length < minLength || data.length > maxLength) {
return "length";
}
if (type === "pin_code" && !/^\d+$/.test(data)) {
return "pin_digits_only";
}
return "";
};
export interface ZwaveCredentialTypeCapability {
num_slots: number;
min_length: number;
max_length: number;
supports_learn: boolean;
}
export interface ZwaveCredentialCapabilities {
supports_user_management: boolean;
max_users: number;
supported_user_types: string[];
max_user_name_length: number;
supported_credential_rules: string[];
supported_credential_types: Partial<
Record<ZwaveCredentialType, ZwaveCredentialTypeCapability>
>;
}
export interface ZwaveCredential {
type: ZwaveCredentialType;
slot: number;
}
export interface ZwaveUser {
user_id: number;
user_name: string | null;
active: boolean;
user_type: string;
credential_rule: string | null;
credentials: ZwaveCredential[];
}
export interface ZwaveUsersResponse {
max_users: number;
users: ZwaveUser[];
}
export interface SetZwaveUserParams {
user_id?: number;
user_name?: string | null;
user_type?: string;
credential_rule?: string;
active?: boolean;
}
export interface SetZwaveUserResult {
user_id: number;
}
export interface SetZwaveCredentialParams {
user_id: number;
credential_type: ZwaveCredentialType;
credential_data: string;
credential_slot?: number;
}
export interface SetZwaveCredentialResult {
credential_slot: number;
user_id: number;
}
export interface DeleteZwaveCredentialParams {
user_id: number;
credential_type: ZwaveCredentialType;
credential_slot: number;
}
// The Z-Wave services key their response by entity_id to support multi-target
// calls. The frontend only ever calls them with a single lock entity, so we
// expect exactly that key. Anything else (no response, mismatched key) is a
// backend contract violation — surface it as a localized error rather than
// letting `cannot read property of undefined` bubble up.
const unwrapEntityResponse = <T>(
hass: HomeAssistant,
response: Record<string, T> | undefined,
entity_id: string
): T => {
const value = response?.[entity_id];
if (value === undefined) {
throw new Error(
hass.localize(
"ui.panel.config.zwave_js.credentials.errors.empty_response"
)
);
}
return value;
};
const callCredentialService = async <T>(
hass: HomeAssistant,
service: string,
entity_id: string,
params: Record<string, unknown> = {}
): Promise<T> => {
// notifyOnError=false — callers surface errors in-dialog instead.
const result = await hass.callService<Record<string, T>>(
"zwave_js",
service,
params,
{ entity_id },
false,
true
);
return unwrapEntityResponse(hass, result.response, entity_id);
};
export const getZwaveCredentialCapabilities = (
hass: HomeAssistant,
entity_id: string
): Promise<ZwaveCredentialCapabilities> =>
callCredentialService<ZwaveCredentialCapabilities>(
hass,
"get_credential_capabilities",
entity_id
);
export const getZwaveUsers = (
hass: HomeAssistant,
entity_id: string
): Promise<ZwaveUsersResponse> =>
callCredentialService<ZwaveUsersResponse>(hass, "get_users", entity_id);
export const setZwaveUser = async (
hass: HomeAssistant,
entity_id: string,
params: SetZwaveUserParams
): Promise<SetZwaveUserResult> => {
// notifyOnError=false — caller surfaces errors in-dialog instead.
const result = await hass.callService<Record<string, SetZwaveUserResult>>(
"zwave_js",
"set_user",
params,
{ entity_id },
false,
true
);
return unwrapEntityResponse(hass, result.response, entity_id);
};
export const deleteZwaveUser = (
hass: HomeAssistant,
entity_id: string,
user_id: number
) =>
hass.callService(
"zwave_js",
"delete_user",
{ user_id },
{ entity_id },
false
);
export const deleteZwaveAllUsers = (hass: HomeAssistant, entity_id: string) =>
hass.callService("zwave_js", "delete_all_users", {}, { entity_id }, false);
export const setZwaveCredential = async (
hass: HomeAssistant,
entity_id: string,
params: SetZwaveCredentialParams
): Promise<SetZwaveCredentialResult> => {
// notifyOnError=false — caller surfaces errors in-dialog instead.
const result = await hass.callService<
Record<string, SetZwaveCredentialResult>
>("zwave_js", "set_credential", params, { entity_id }, false, true);
return unwrapEntityResponse(hass, result.response, entity_id);
};
export const deleteZwaveCredential = (
hass: HomeAssistant,
entity_id: string,
params: DeleteZwaveCredentialParams
) =>
hass.callService(
"zwave_js",
"delete_credential",
params,
{ entity_id },
false
);
@@ -18,7 +18,7 @@ import "../../../components/ha-slider";
import "../../../components/ha-time-input";
import "../../../components/input/ha-input";
import { isTiltOnly } from "../../../data/cover";
import { isUnavailableState, UNAVAILABLE } from "../../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { ImageEntity } from "../../../data/image";
import { computeImageUrl } from "../../../data/image";
import "../../../panels/lovelace/components/hui-timestamp-display";
@@ -108,14 +108,13 @@ class EntityPreviewRow extends LitElement {
private _renderEntityState(stateObj: HassEntity): TemplateResult | string {
const domain = stateObj.entity_id.split(".", 1)[0];
const disabled = stateObj.state === UNAVAILABLE;
const noValue =
stateObj.state === UNAVAILABLE || stateObj.state === UNKNOWN;
if (domain === "button") {
return html`
<ha-button
appearance="plain"
size="small"
.disabled=${isUnavailableState(stateObj.state)}
>
<ha-button appearance="plain" size="small" .disabled=${disabled}>
${this.hass.localize("ui.card.button.press")}
</ha-button>
`;
@@ -151,19 +150,15 @@ class EntityPreviewRow extends LitElement {
return html`
<ha-date-input
.locale=${this.hass.locale}
.disabled=${isUnavailableState(stateObj.state)}
.value=${isUnavailableState(stateObj.state)
? undefined
: stateObj.state}
.disabled=${disabled}
.value=${noValue ? undefined : stateObj.state}
>
</ha-date-input>
`;
}
if (domain === "datetime") {
const dateObj = isUnavailableState(stateObj.state)
? undefined
: new Date(stateObj.state);
const dateObj = noValue ? undefined : new Date(stateObj.state);
const time = dateObj ? format(dateObj, "HH:mm:ss") : undefined;
const date = dateObj ? format(dateObj, "yyyy-MM-dd") : undefined;
return html`
@@ -172,12 +167,12 @@ class EntityPreviewRow extends LitElement {
.label=${computeStateName(stateObj)}
.locale=${this.hass.locale}
.value=${date}
.disabled=${isUnavailableState(stateObj.state)}
.disabled=${disabled}
>
</ha-date-input>
<ha-time-input
.value=${time}
.disabled=${isUnavailableState(stateObj.state)}
.disabled=${disabled}
.locale=${this.hass.locale}
></ha-time-input>
</div>
@@ -187,7 +182,7 @@ class EntityPreviewRow extends LitElement {
if (domain === "event") {
return html`
<div class="when">
${isUnavailableState(stateObj.state)
${noValue
? this.hass.formatEntityState(stateObj)
: html`<hui-timestamp-display
.hass=${this.hass}
@@ -196,7 +191,7 @@ class EntityPreviewRow extends LitElement {
></hui-timestamp-display>`}
</div>
<div class="what">
${isUnavailableState(stateObj.state)
${noValue
? nothing
: this.hass.formatEntityAttributeValue(stateObj, "event_type")}
</div>
@@ -206,9 +201,7 @@ class EntityPreviewRow extends LitElement {
const toggleDomains = ["fan", "light", "remote", "siren", "switch"];
if (toggleDomains.includes(domain)) {
const showToggle =
stateObj.state === "on" ||
stateObj.state === "off" ||
isUnavailableState(stateObj.state);
stateObj.state === "on" || stateObj.state === "off" || noValue;
return html`
${showToggle
? html`
@@ -241,7 +234,7 @@ class EntityPreviewRow extends LitElement {
if (domain === "lock") {
return html`
<ha-button
.disabled=${isUnavailableState(stateObj.state)}
.disabled=${disabled}
class="text-content"
appearance="plain"
size="small"
@@ -266,7 +259,7 @@ class EntityPreviewRow extends LitElement {
<div class="numberflex">
<ha-slider
labeled
.disabled=${stateObj.state === UNAVAILABLE}
.disabled=${disabled}
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
.max=${Number(stateObj.attributes.max)}
@@ -280,7 +273,7 @@ class EntityPreviewRow extends LitElement {
: html`<div class="numberflex numberstate">
<ha-input
auto-validate
.disabled=${stateObj.state === UNAVAILABLE}
.disabled=${disabled}
pattern="[0-9]+([\\.][0-9]+)?"
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
@@ -303,7 +296,7 @@ class EntityPreviewRow extends LitElement {
<ha-select
.label=${computeStateName(stateObj)}
.value=${stateObj.state}
.disabled=${stateObj.state === UNAVAILABLE}
.disabled=${disabled}
.options=${stateObj.attributes.options?.map((option) => ({
value: option,
label: this.hass!.formatEntityState(stateObj, option),
@@ -317,7 +310,7 @@ class EntityPreviewRow extends LitElement {
const showSensor =
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
stateObj.attributes.device_class
) && !isUnavailableState(stateObj.state);
) && !noValue;
return html`
${showSensor
? html`
@@ -339,7 +332,7 @@ class EntityPreviewRow extends LitElement {
return html`
<ha-input
.label=${computeStateName(stateObj)}
.disabled=${isUnavailableState(stateObj.state)}
.disabled=${disabled}
.value=${stateObj.state}
.minlength=${stateObj.attributes.min}
.maxlength=${stateObj.attributes.max}
@@ -354,11 +347,9 @@ class EntityPreviewRow extends LitElement {
if (domain === "time") {
return html`
<ha-time-input
.value=${isUnavailableState(stateObj.state)
? undefined
: stateObj.state}
.value=${noValue ? undefined : stateObj.state}
.locale=${this.hass.locale}
.disabled=${isUnavailableState(stateObj.state)}
.disabled=${disabled}
></ha-time-input>
`;
}
@@ -366,7 +357,7 @@ class EntityPreviewRow extends LitElement {
if (domain === "weather") {
return html`
<div>
${isUnavailableState(stateObj.state) ||
${noValue ||
stateObj.attributes.temperature === undefined ||
stateObj.attributes.temperature === null
? this.hass.formatEntityState(stateObj)
@@ -65,7 +65,7 @@ export class FlowPreviewGeneric extends LitElement {
}
const now = new Date().toISOString();
this._preview = {
entity_id: `${this.stepId}.___flow_preview___`,
entity_id: `${preview.domain ?? this.stepId}.___flow_preview___`,
last_changed: now,
last_updated: now,
context: { id: "", parent_id: null, user_id: null },
@@ -85,7 +85,8 @@ export class FlowPreviewGeneric extends LitElement {
if (
this.flowType !== "config_flow" &&
this.flowType !== "options_flow" &&
this.flowType !== "config_subentries_flow"
this.flowType !== "config_subentries_flow" &&
this.flowType !== "repair_flow"
) {
return;
}
@@ -130,7 +130,7 @@ class FlowPreviewTemplate extends LitElement {
this._listeners = preview.listeners;
const now = new Date().toISOString();
this._preview = {
entity_id: `${this.stepId}.___flow_preview___`,
entity_id: `${preview.domain ?? this.stepId}.___flow_preview___`,
last_changed: now,
last_updated: now,
context: { id: "", parent_id: null, user_id: null },
+17 -3
View File
@@ -9,6 +9,8 @@ import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog-header";
import "../../components/ha-svg-icon";
import "../../components/ha-textarea";
import type { HaTextArea } from "../../components/ha-textarea";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
import type { HomeAssistant } from "../../types";
@@ -28,7 +30,7 @@ class DialogBox extends LitElement {
@state() private _validInput = true;
@query("ha-input") private _textField?: HaInput;
@query("ha-input, ha-textarea") private _textField?: HaInput | HaTextArea;
private _closePromise?: Promise<void>;
@@ -109,7 +111,7 @@ class DialogBox extends LitElement {
</ha-dialog-header>
<div id="dialog-box-description">
${this._params.text ? html` <p>${this._params.text}</p> ` : ""}
${this._params.prompt
${this._params.prompt && !this._params.multiline
? html`
<ha-input
autofocus
@@ -131,7 +133,19 @@ class DialogBox extends LitElement {
: nothing}
</ha-input>
`
: nothing}
: this._params.prompt && this._params.multiline
? html`
<ha-textarea
resize="auto"
autofocus
.value=${this._params.defaultValue}
.placeholder=${this._params.placeholder}
.label=${this._params.inputLabel}
.disabled=${this._loading}
@input=${this._validateInput}
></ha-textarea>
`
: nothing}
</div>
<ha-dialog-footer slot="footer">
${confirmPrompt
+1
View File
@@ -33,6 +33,7 @@ export interface PromptDialogParams extends BaseDialogBoxParams {
inputMin?: number | string;
inputMax?: number | string;
action?: (value?: string) => Promise<void>;
multiline?: boolean;
}
export interface DialogBoxParams
+131
View File
@@ -0,0 +1,131 @@
import { navigate } from "../../common/navigate";
import type { LocalizeFunc } from "../../common/translations/localize";
import { createSearchParam } from "../../common/url/search-params";
import type { SingleHassServiceTarget } from "../../data/target";
import {
ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM,
} from "../../panels/config/automation/show-add-automation-element-dialog";
import type { HomeAssistant, TranslationDict } from "../../types";
/** Add to action keys are the keys of the translation dictionary for the add to actions. */
export type AddToActionKey =
TranslationDict["ui"]["dialogs"]["more_info_control"]["add_to"]["actions"] extends infer Actions
? keyof Actions
: never;
interface BaseEntityAddToAction {
/** Whether the action is enabled and can be selected. */
enabled: boolean;
/** Translated name of the action */
name: string;
/** Optional translated description of the action */
description?: string;
/** MDI icon name (e.g., "mdi:car") */
icon: string;
}
export interface DefaultEntityAddToAction extends BaseEntityAddToAction {
/** Type of action handled in the frontend */
type: "default";
/** Stable key used to resolve the action handler */
key: AddToActionKey;
}
export interface ExternalEntityAddToAction extends BaseEntityAddToAction {
/** Type of action. External is handled by external apps instead of in the frontend */
type: "external";
/** Opaque payload for external action handling */
payload?: string;
}
export type EntityAddToAction =
| DefaultEntityAddToAction
| ExternalEntityAddToAction;
export type EntityAddToActions = EntityAddToAction[];
interface ActionDefinition {
translation_key: AddToActionKey;
icon: string;
}
export const DEFAULT_ACTION_DEFS: ActionDefinition[] = [
{
translation_key: "automation_trigger",
icon: "mdi:robot-outline",
},
{
translation_key: "automation_condition",
icon: "mdi:playlist-check",
},
{
translation_key: "automation_action",
icon: "mdi:play-circle-outline",
},
{
translation_key: "script_action",
icon: "mdi:script-text-outline",
},
];
export const getDefaultAddToActions = (
states: HomeAssistant["states"],
localize: LocalizeFunc,
formatEntityName: HomeAssistant["formatEntityName"],
entityId: string
): EntityAddToActions =>
DEFAULT_ACTION_DEFS.map(
(def: ActionDefinition): EntityAddToAction => ({
type: "default",
key: def.translation_key,
enabled: true,
name: localize(
`ui.dialogs.more_info_control.add_to.actions.${def.translation_key}`,
{
target:
states[entityId] !== undefined
? formatEntityName(states[entityId], undefined)
: entityId,
}
),
icon: def.icon,
})
);
/** Handler for adding a target to an automation/script. */
export function addToActionHandler(
key: AddToActionKey,
target: SingleHassServiceTarget
): Promise<boolean> {
const searchParams: Record<string, string> = {};
if (target.entity_id) {
searchParams[ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM] = target.entity_id;
} else if (target.device_id) {
searchParams[ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM] = target.device_id;
} else if (target.area_id) {
searchParams[ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM] = target.area_id;
}
const params = (addElement: string) =>
`?${createSearchParam({
[ADD_AUTOMATION_ELEMENT_QUERY_PARAM]: addElement,
...searchParams,
})}`;
switch (key) {
case "automation_trigger":
return navigate(`/config/automation/edit/new${params("trigger")}`);
case "automation_condition":
return navigate(`/config/automation/edit/new${params("condition")}`);
case "automation_action":
return navigate(`/config/automation/edit/new${params("action")}`);
case "script_action":
return navigate(`/config/script/edit/new${params("action")}`);
default:
return Promise.reject(new Error(`Unknown action key ${key}`));
}
}
@@ -3,7 +3,7 @@ import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-absolute-time";
import "../../../components/ha-relative-time";
import { isUnavailableState } from "../../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { LightEntity } from "../../../data/light";
import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor";
import "../../../panels/lovelace/components/hui-timestamp-display";
@@ -24,7 +24,8 @@ export class HaMoreInfoStateHeader extends LitElement {
private _localizeState(): TemplateResult | string {
if (
this.stateObj.attributes.device_class === SENSOR_DEVICE_CLASS_TIMESTAMP &&
!isUnavailableState(this.stateObj.state)
this.stateObj.state !== UNAVAILABLE &&
this.stateObj.state !== UNKNOWN
) {
return html`
<hui-timestamp-display
@@ -4,7 +4,7 @@ import { customElement, property } from "lit/decorators";
import "../../../components/ha-button";
import "../../../components/ha-relative-time";
import { triggerAutomationActions } from "../../../data/automation";
import { isUnavailableState } from "../../../data/entity/entity";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { HomeAssistant } from "../../../types";
@customElement("more-info-automation")
@@ -34,7 +34,7 @@ class MoreInfoAutomation extends LitElement {
appearance="plain"
size="small"
@click=${this._runActions}
.disabled=${isUnavailableState(this.stateObj!.state)}
.disabled=${this.stateObj!.state === UNAVAILABLE}
>
${this.hass.localize("ui.card.automation.trigger")}
</ha-button>
@@ -2,7 +2,7 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-button";
import { isUnavailableState } from "../../../data/entity/entity";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { HomeAssistant } from "../../../types";
@customElement("more-info-counter")
@@ -16,7 +16,7 @@ class MoreInfoCounter extends LitElement {
return nothing;
}
const disabled = isUnavailableState(this.stateObj.state);
const disabled = this.stateObj.state === UNAVAILABLE;
return html`
<div class="actions">
@@ -4,7 +4,7 @@ import { customElement, property } from "lit/decorators";
import "../../../components/ha-date-input";
import "../../../components/ha-time-input";
import { setDateValue } from "../../../data/date";
import { isUnavailableState, UNAVAILABLE } from "../../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
@customElement("more-info-date")
@@ -21,10 +21,9 @@ class MoreInfoDate extends LitElement {
return html`
<ha-date-input
.locale=${this.hass.locale}
.value=${isUnavailableState(this.stateObj.state)
.value=${this.stateObj.state === UNKNOWN
? undefined
: this.stateObj.state}
.disabled=${this.stateObj.state === UNAVAILABLE}
@value-changed=${this._dateChanged}
>
</ha-date-input>
@@ -5,7 +5,7 @@ import { customElement, property } from "lit/decorators";
import "../../../components/ha-date-input";
import "../../../components/ha-time-input";
import { setDateTimeValue } from "../../../data/datetime";
import { isUnavailableState, UNAVAILABLE } from "../../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
@customElement("more-info-datetime")
@@ -19,23 +19,22 @@ class MoreInfoDatetime extends LitElement {
return nothing;
}
const dateObj = isUnavailableState(this.stateObj.state)
? undefined
: new Date(this.stateObj.state);
const dateObj =
this.stateObj.state === UNKNOWN
? undefined
: new Date(this.stateObj.state);
const time = dateObj ? format(dateObj, "HH:mm:ss") : undefined;
const date = dateObj ? format(dateObj, "yyyy-MM-dd") : undefined;
return html`<ha-date-input
.locale=${this.hass.locale}
.value=${date}
.disabled=${this.stateObj.state === UNAVAILABLE}
@value-changed=${this._dateChanged}
>
</ha-date-input>
<ha-time-input
.value=${time}
.locale=${this.hass.locale}
.disabled=${this.stateObj.state === UNAVAILABLE}
@value-changed=${this._timeChanged}
@click=${this._stopEventPropagation}
></ha-time-input>`;
@@ -200,12 +200,13 @@ class MoreInfoMediaPlayer extends LitElement {
protected _renderSourceControl() {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, MediaPlayerEntityFeature.SELECT_SOURCE) ||
!this.stateObj.attributes.source_list?.length
!supportsFeature(this.stateObj, MediaPlayerEntityFeature.SELECT_SOURCE)
) {
return nothing;
}
const sourceList = this.stateObj.attributes.source_list || [];
return html`<ha-tooltip for="source-button">
${this.hass.localize(`ui.card.media_player.source`)}
</ha-tooltip>
@@ -217,7 +218,7 @@ class MoreInfoMediaPlayer extends LitElement {
.path=${mdiLoginVariant}
>
</ha-icon-button>
${this.stateObj.attributes.source_list!.map(
${sourceList.map(
(source) =>
html`<ha-dropdown-item
.value=${source}
@@ -11,7 +11,7 @@ import "../../../components/ha-control-button-group";
import "../../../components/ha-markdown";
import "../../../components/ha-relative-time";
import "../../../components/ha-service-control";
import { isUnavailableState } from "../../../data/entity/entity";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { ScriptEntity } from "../../../data/script";
import {
@@ -141,7 +141,7 @@ class MoreInfoScript extends LitElement {
<ha-control-button
class="run-button"
@click=${this._runScript}
.disabled=${isUnavailableState(stateObj.state) || !this._canRun()}
.disabled=${stateObj.state === UNAVAILABLE || !this._canRun()}
>
<ha-svg-icon .path=${mdiPlay}></ha-svg-icon>
${this.hass!.localize("ui.card.script.run")}
@@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-date-input";
import "../../../components/ha-time-input";
import { isUnavailableState, UNAVAILABLE } from "../../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import { setTimeValue } from "../../../data/time";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
@@ -20,11 +20,10 @@ class MoreInfoTime extends LitElement {
return html`
<ha-time-input
.value=${isUnavailableState(this.stateObj.state)
.value=${this.stateObj.state === UNKNOWN
? undefined
: this.stateObj.state}
.locale=${this.hass.locale}
.disabled=${this.stateObj.state === UNAVAILABLE}
@value-changed=${this._timeChanged}
@click=${this._stopEventPropagation}
></ha-time-input>
@@ -15,7 +15,7 @@ import "../../../components/item/ha-row-item";
import "../../../components/progress/ha-progress-bar";
import type { BackupConfig } from "../../../data/backup";
import { fetchBackupConfig } from "../../../data/backup";
import { isUnavailableState } from "../../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import { getSupervisorUpdateConfig } from "../../../data/supervisor/update";
@@ -176,7 +176,8 @@ class MoreInfoUpdate extends LitElement {
if (
!this.hass ||
!this.stateObj ||
isUnavailableState(this.stateObj.state)
this.stateObj.state === UNAVAILABLE ||
this.stateObj.state === UNKNOWN
) {
return nothing;
}
+123 -55
View File
@@ -5,14 +5,18 @@ import "../../components/ha-icon";
import "../../components/ha-spinner";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-base";
import type {
ExternalEntityAddToAction,
ExternalEntityAddToActions,
} from "../../external_app/external_messaging";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import { showToast } from "../../util/toast";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import {
type EntityAddToAction,
type EntityAddToActions,
addToActionHandler,
getDefaultAddToActions,
} from "./add-to";
@customElement("ha-more-info-add-to")
export class HaMoreInfoAddTo extends LitElement {
@@ -20,54 +24,112 @@ export class HaMoreInfoAddTo extends LitElement {
@property({ attribute: false }) public entityId!: string;
@state() private _externalActions?: ExternalEntityAddToActions = {
actions: [],
};
@state() private _defaultActions: EntityAddToActions = [];
@state() private _externalActions: EntityAddToActions = [];
@state() private _loading = true;
private async _loadExternalActions() {
private async _loadActions() {
this._defaultActions = getDefaultAddToActions(
this.hass.states,
this.hass.localize,
this.hass.formatEntityName,
this.entityId
);
this._externalActions = [];
if (this.hass.auth.external?.config.hasEntityAddTo) {
this._externalActions =
await this.hass.auth.external?.sendMessage<"entity/add_to/get_actions">(
{
type: "entity/add_to/get_actions",
payload: { entity_id: this.entityId },
}
);
try {
const response =
await this.hass.auth.external?.sendMessage<"entity/add_to/get_actions">(
{
type: "entity/add_to/get_actions",
payload: { entity_id: this.entityId },
}
);
if (response?.actions) {
this._externalActions = response.actions.map((action) => ({
type: "external",
enabled: action.enabled,
name: action.name,
description: action.details,
icon: action.mdi_icon,
payload: action.app_payload,
}));
}
} catch (err: unknown) {
// eslint-disable-next-line no-console
console.warn("Failed to fetch add to actions", err);
}
}
}
private async _actionSelected(ev: CustomEvent) {
const action = (ev.currentTarget as any)
.action as ExternalEntityAddToAction;
private async _actionSelected(
ev: HASSDomCurrentTargetEvent<
HaListItemButton & {
action: EntityAddToAction;
}
>
) {
const action = ev.currentTarget.action;
if (!action.enabled) {
return;
}
try {
await this.hass.auth.external!.fireMessage({
type: "entity/add_to",
payload: {
entity_id: this.entityId,
app_payload: action.app_payload,
},
});
fireEvent(this, "add-to-action-selected");
} catch (err: any) {
showToast(this, {
message: this.hass.localize(
"ui.dialogs.more_info_control.add_to.action_failed",
{
error: err.message || err,
}
),
});
if (action.type === "external") {
try {
if (!action.payload) {
throw new Error("Missing external action payload");
}
this.hass.auth.external!.fireMessage({
type: "entity/add_to",
payload: {
entity_id: this.entityId,
app_payload: action.payload,
},
});
fireEvent(this, "add-to-action-selected");
} catch (err: unknown) {
showToast(this, {
message: this.hass.localize(
"ui.dialogs.more_info_control.add_to.action_failed",
{
error: err instanceof Error ? err.message : String(err),
}
),
});
}
return;
}
if (action.type !== "default") {
return;
}
addToActionHandler(action.key, { entity_id: this.entityId });
}
private _renderActionItems(actions: EntityAddToActions) {
return actions.map(
(action) => html`
<ha-list-item-button
.disabled=${!action.enabled}
.action=${action}
@click=${this._actionSelected}
>
<ha-icon slot="start" .icon=${action.icon}></ha-icon>
<span slot="headline">${action.name}</span>
${action.description
? html`<span slot="supporting-text">${action.description}</span>`
: nothing}
</ha-list-item-button>
`
);
}
protected async firstUpdated() {
await this._loadExternalActions();
await this._loadActions();
this._loading = false;
}
@@ -80,7 +142,7 @@ export class HaMoreInfoAddTo extends LitElement {
`;
}
if (!this._externalActions?.actions.length) {
if (!this._defaultActions.length && !this._externalActions.length) {
return html`
<ha-alert alert-type="info">
${this.hass.localize(
@@ -92,30 +154,27 @@ export class HaMoreInfoAddTo extends LitElement {
return html`
<ha-list-base>
${this._externalActions?.actions.map(
(action) => html`
<ha-list-item-button
.disabled=${!action.enabled}
.action=${action}
@click=${this._actionSelected}
>
<ha-icon slot="start" .icon=${action.mdi_icon}></ha-icon>
<span slot="headline">${action.name}</span>
${action.details
? html`<span slot="supporting-text">${action.details}</span>`
: nothing}
</ha-list-item-button>
`
)}
${this._renderActionItems(this._defaultActions)}
</ha-list-base>
${this._externalActions.length
? html`
<h2 class="section-title">
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.app_actions"
)}
</h2>
<ha-list-base>
${this._renderActionItems(this._externalActions)}
</ha-list-base>
`
: nothing}
`;
}
static styles = css`
:host {
display: block;
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-6)
var(--ha-space-6);
padding: var(--ha-space-3) 0 var(--ha-space-4);
}
.loading {
@@ -125,6 +184,15 @@ export class HaMoreInfoAddTo extends LitElement {
padding: var(--ha-space-8);
}
.section-title {
padding: 0 var(--ha-space-6);
margin: var(--ha-space-4) 0 var(--ha-space-1);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-normal);
color: var(--secondary-text-color);
}
ha-icon {
display: flex;
align-items: center;
+46 -20
View File
@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiBackupRestore,
mdiChartBoxOutline,
@@ -17,13 +18,13 @@ import {
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import { classMap } from "lit/directives/class-map";
import { keyed } from "lit/directives/keyed";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
@@ -57,10 +58,12 @@ import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../data/entity/entity_registry";
import { subscribeLabFeature } from "../../data/labs";
import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { ScrollableFadeMixin } from "../../mixins/scrollable-fade-mixin";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
@@ -70,12 +73,12 @@ import "../../state-summary/state-card-content";
import type { HomeAssistant } from "../../types";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import {
computeShowHistoryComponent,
computeShowLogBookComponent,
DOMAINS_WITH_MORE_INFO,
EDITABLE_DOMAINS_WITH_ID,
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
type MoreInfoView,
computeShowHistoryComponent,
computeShowLogBookComponent,
} from "./const";
import "./controls/more-info-default";
import type { FavoritesDialogContext } from "./favorites";
@@ -117,7 +120,9 @@ declare global {
const DEFAULT_VIEW: MoreInfoView = "info";
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
export class MoreInfoDialog extends SubscribeMixin(
ScrollableFadeMixin(LitElement)
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@@ -156,6 +161,8 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
@state() private _sensorNumericDeviceClasses?: string[] = [];
@state() private _newTriggersAndConditions = false;
protected scrollFadeThreshold = 24;
protected get scrollableElement(): HTMLElement | null {
@@ -254,7 +261,24 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
}
private _shouldShowAddEntityTo(): boolean {
return !!this.hass.auth.external?.config.hasEntityAddTo;
// When new_triggers_conditions labs feature is promoted, this whole check can be removed.
return (
this._newTriggersAndConditions ||
!!this.hass.auth.external?.config.hasEntityAddTo
);
}
protected hassSubscribe() {
return [
subscribeLabFeature(
this.hass.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
}
),
];
}
private _getDeviceId(): string | null {
@@ -680,6 +704,21 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
.path=${mdiDotsVertical}
></ha-icon-button>
${this._shouldShowAddEntityTo()
? html`
<ha-dropdown-item value="add_to">
<ha-svg-icon
slot="icon"
.path=${mdiPlusBoxMultipleOutline}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
</ha-dropdown-item>
<wa-divider></wa-divider>
`
: nothing}
${supportsFavorites
? html`
<ha-dropdown-item value="toggle_edit">
@@ -769,19 +808,6 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
"ui.dialogs.more_info_control.details"
)}
</ha-dropdown-item>
${this._shouldShowAddEntityTo()
? html`
<ha-dropdown-item value="add_to">
<ha-svg-icon
slot="icon"
.path=${mdiPlusBoxMultipleOutline}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_entity_to"
)}
</ha-dropdown-item>
`
: nothing}
</ha-dropdown>
`
: !__DEMO__ && this._shouldShowAddEntityTo()
@@ -789,7 +815,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
<ha-icon-button
slot="headerActionItems"
.label=${this.hass.localize(
"ui.dialogs.more_info_control.add_entity_to"
"ui.dialogs.more_info_control.add_to.title"
)}
.path=${mdiPlusBoxMultipleOutline}
@click=${this._goToAddEntityTo}
@@ -343,9 +343,9 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
this._step = this._previousSteps.pop()!;
}
private _goToNextStep(ev?: CustomEvent) {
private async _goToNextStep(ev?: CustomEvent) {
if (ev?.detail?.updateConfig) {
this._fetchAssistConfiguration();
await this._fetchAssistConfiguration();
}
if (ev?.detail?.nextStep) {
this._nextStep = ev.detail.nextStep;
@@ -199,13 +199,13 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
this._detailState = this.hass.localize(
`ui.panel.config.voice_assistants.satellite_wizard.local.state.installing_${this._ttsProviderName}`
);
await installHassioAddon(this.hass, this._ttsAddonName);
await installHassioAddon(this.hass.callWS, this._ttsAddonName);
}
if (!ttsAddon || ttsAddon.state !== "started") {
this._detailState = this.hass.localize(
`ui.panel.config.voice_assistants.satellite_wizard.local.state.starting_${this._ttsProviderName}`
);
await startHassioAddon(this.hass, this._ttsAddonName);
await startHassioAddon(this.hass.callWS, this._ttsAddonName);
}
this._detailState = this.hass.localize(
`ui.panel.config.voice_assistants.satellite_wizard.local.state.setup_${this._ttsProviderName}`
@@ -217,13 +217,13 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
this._detailState = this.hass.localize(
`ui.panel.config.voice_assistants.satellite_wizard.local.state.installing_${this._sttProviderName}`
);
await installHassioAddon(this.hass, this._sttAddonName);
await installHassioAddon(this.hass.callWS, this._sttAddonName);
}
if (!sttAddon || sttAddon.state !== "started") {
this._detailState = this.hass.localize(
`ui.panel.config.voice_assistants.satellite_wizard.local.state.starting_${this._sttProviderName}`
);
await startHassioAddon(this.hass, this._sttAddonName);
await startHassioAddon(this.hass.callWS, this._sttAddonName);
}
this._detailState = this.hass.localize(
`ui.panel.config.voice_assistants.satellite_wizard.local.state.setup_${this._sttProviderName}`
+55 -23
View File
@@ -1,18 +1,25 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { LOCAL_TIME_ZONE } from "../common/datetime/resolve-time-zone";
import memoizeOne from "memoize-one";
import {
HAS_RESOLVED_IANA_TIME_ZONE,
LOCAL_TIME_ZONE,
} from "../common/datetime/resolve-time-zone";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
import "../components/ha-button";
import { COUNTRIES } from "../components/ha-country-picker";
import "../components/ha-form/ha-form";
import type { HaForm } from "../components/ha-form/ha-form";
import type { HaFormSchema } from "../components/ha-form/types";
import "../components/ha-spinner";
import type { ConfigUpdateValues } from "../data/core";
import { saveCoreConfig } from "../data/core";
import { countryCurrency } from "../data/currency";
import { onboardCoreConfigStep } from "../data/onboarding";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HomeAssistant } from "../types";
import { getLocalLanguage } from "../util/common-translation";
import "./onboarding-location";
@@ -28,7 +35,9 @@ class OnboardingCoreConfig extends LitElement {
private _elevation = "0";
private _timeZone: ConfigUpdateValues["time_zone"] = LOCAL_TIME_ZONE;
@state() private _timeZone: ConfigUpdateValues["time_zone"] = LOCAL_TIME_ZONE;
@state() private _timeZoneDetected = HAS_RESOLVED_IANA_TIME_ZONE;
private _language: ConfigUpdateValues["language"] = getLocalLanguage();
@@ -42,7 +51,29 @@ class OnboardingCoreConfig extends LitElement {
@state() private _skipCore = false;
@query("ha-country-picker") private _countryPicker?: HTMLElement;
@query("ha-form") private _form?: HaForm;
private _schema = memoizeOne((includeTimeZone: boolean): HaFormSchema[] => [
{
name: "country",
required: true,
selector: { country: null },
},
...(includeTimeZone
? ([
{
name: "time_zone",
required: true,
selector: { timezone: null },
},
] satisfies HaFormSchema[])
: []),
]);
private _computeLabel = (schema: HaFormSchema) =>
this.hass.localize(
`ui.panel.config.core.section.core.core_config.${schema.name}` as any
);
protected render(): TemplateResult {
if (!this._location) {
@@ -68,17 +99,17 @@ class OnboardingCoreConfig extends LitElement {
)}
</p>
<ha-country-picker
class="flex"
<ha-form
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.country"
) || "Country"}
required
.data=${{
country: this._country ?? "",
time_zone: this._timeZone,
}}
.schema=${this._schema(!this._timeZoneDetected)}
.computeLabel=${this._computeLabel}
.disabled=${this._working}
.value=${this._countryValue}
@value-changed=${this._handleCountryChanged}
></ha-country-picker>
@value-changed=${this._handleFormChanged}
></ha-form>
<div class="footer">
<ha-button @click=${this._save} .disabled=${this._working}>
@@ -99,12 +130,12 @@ class OnboardingCoreConfig extends LitElement {
});
}
private get _countryValue() {
return this._country || "";
}
private _handleCountryChanged(ev: ValueChangedEvent<string>) {
this._country = ev.detail.value;
private _handleFormChanged(ev: CustomEvent) {
const value = ev.detail.value as { country?: string; time_zone?: string };
this._country = value.country || undefined;
if (value.time_zone) {
this._timeZone = value.time_zone;
}
}
private async _locationChanged(ev) {
@@ -123,11 +154,12 @@ class OnboardingCoreConfig extends LitElement {
}
if (ev.detail.value.timezone) {
this._timeZone = ev.detail.value.timezone;
this._timeZoneDetected = true;
}
if (ev.detail.value.unit_system) {
this._unitSystem = ev.detail.value.unit_system;
}
if (this._country) {
if (this._country && this._timeZoneDetected) {
this._skipCore = true;
this._save(ev);
return;
@@ -145,11 +177,11 @@ class OnboardingCoreConfig extends LitElement {
fireEvent(this, "onboarding-progress", { increase: 0.5 });
await this.updateComplete;
setTimeout(() => this._countryPicker!.focus(), 100);
setTimeout(() => this._form?.focus(), 100);
}
private async _save(ev) {
if (!this._location || !this._country) {
if (!this._location || !this._country || !this._timeZone) {
return;
}
ev.preventDefault();
@@ -166,7 +198,7 @@ class OnboardingCoreConfig extends LitElement {
this._unitSystem || ["US", "MM", "LR"].includes(this._country)
? "us_customary"
: "metric",
time_zone: this._timeZone || "UTC",
time_zone: this._timeZone,
currency: this._currency || countryCurrency[this._country] || "EUR",
country: this._country,
language: this._language,
+5 -2
View File
@@ -6,6 +6,7 @@ import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { IFRAME_SANDBOX } from "../../util/iframe";
import { navigate } from "../../common/navigate";
import { computeRouteTail } from "../../common/url/route";
import { nextRender } from "../../common/util/render-status";
@@ -136,6 +137,8 @@ class HaPanelApp extends LitElement {
})}
title=${this._addon.name}
src=${this._addon.ingress_url!}
.sandbox=${IFRAME_SANDBOX}
allow="microphone; camera; clipboard-write"
@load=${this._checkLoaded}
${ref(this._iframeRef)}
>
@@ -213,7 +216,7 @@ class HaPanelApp extends LitElement {
let addon: HassioAddonDetails;
try {
addon = await fetchHassioAddonInfo(this.hass, addonSlug);
addon = await fetchHassioAddonInfo(this.hass.callWS, addonSlug);
} catch (err: any) {
await this._showErrorAndNavigateHome(
addonSlug,
@@ -253,7 +256,7 @@ class HaPanelApp extends LitElement {
);
// Set auto-retry window for after starting the app
this._autoRetryUntil = Date.now() + START_WAIT_TIME;
await startHassioAddon(this.hass, addonSlug);
await startHassioAddon(this.hass.callWS, addonSlug);
this._fetchData(addonSlug);
return;
} catch (_err) {
+14 -10
View File
@@ -190,16 +190,20 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
.label=${this.hass.localize("ui.common.refresh")}
@click=${this._handleRefresh}
></ha-icon-button>
${showPane && this.hass.user?.is_admin
? html`<ha-list slot="pane" multi}>${calendarItems}</ha-list>
<ha-list-item
graphic="icon"
slot="pane-footer"
@click=${this._addCalendar}
>
<ha-svg-icon .path=${mdiPlus} slot="graphic"></ha-svg-icon>
${this.hass.localize("ui.components.calendar.create_calendar")}
</ha-list-item>`
${showPane
? html`<ha-list slot="pane" multi>${calendarItems}</ha-list>${this
.hass.user?.is_admin
? html`<ha-list-item
graphic="icon"
slot="pane-footer"
@click=${this._addCalendar}
>
<ha-svg-icon .path=${mdiPlus} slot="graphic"></ha-svg-icon>
${this.hass.localize(
"ui.components.calendar.create_calendar"
)}
</ha-list-item>`
: nothing}`
: nothing}
<ha-full-calendar
add-fab
@@ -3,7 +3,7 @@ import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../../../components/ha-bar";
import "../../../../../components/ha-settings-row";
import "../../../../../components/item/ha-row-item";
import { roundWithOneDecimal } from "../../../../../util/calculate";
@customElement("supervisor-app-metric")
@@ -16,9 +16,9 @@ class SupervisorAppMetric extends LitElement {
protected render(): TemplateResult {
const roundedValue = roundWithOneDecimal(this.value);
return html`<ha-settings-row empty>
<span slot="heading"> ${this.description} </span>
<div slot="description" .title=${this.tooltip ?? ""}>
return html`<ha-row-item empty>
<span slot="headline"> ${this.description} </span>
<div slot="supporting-text" .title=${this.tooltip ?? ""}>
<span class="value"> ${roundedValue} % </span>
<ha-bar
class=${classMap({
@@ -28,16 +28,14 @@ class SupervisorAppMetric extends LitElement {
.value=${this.value}
></ha-bar>
</div>
</ha-settings-row>`;
</ha-row-item>`;
}
static styles = css`
ha-settings-row {
padding: 0;
height: 54px;
ha-row-item {
width: 100%;
}
ha-settings-row > div[slot="description"] {
ha-row-item > div[slot="supporting-text"] {
white-space: normal;
color: var(--secondary-text-color);
display: flex;
@@ -1,283 +0,0 @@
import {
css,
type CSSResultGroup,
html,
LitElement,
nothing,
type PropertyValues,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { atLeastVersion } from "../../../../../common/config/version";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-faded";
import "../../../../../components/ha-markdown";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-switch";
import type { HaSwitch } from "../../../../../components/ha-switch";
import "../../../../../components/item/ha-row-item";
import type { HassioAddonDetails } from "../../../../../data/hassio/addon";
import {
fetchHassioAddonChangelog,
updateHassioAddon,
} from "../../../../../data/hassio/addon";
import {
extractApiErrorMessage,
ignoreSupervisorError,
} from "../../../../../data/hassio/common";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { extractChangelog } from "../util/supervisor-app";
declare global {
interface HASSDomEvents {
"update-complete": undefined;
}
}
@customElement("supervisor-app-update-available-card")
class SupervisorAppUpdateAvailableCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public addon!: HassioAddonDetails;
@state() private _changelogContent?: string;
@state() private _updating = false;
@state() private _error?: string;
protected render() {
if (!this.addon) {
return nothing;
}
const createBackupTexts = this._computeCreateBackupTexts();
return html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.apps.dashboard.update_available.update_name",
{
name: this.addon.name,
}
)}
>
<div class="card-content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
${this.addon.version === this.addon.version_latest
? html`<p>
${this.hass.localize(
"ui.panel.config.apps.dashboard.update_available.no_update",
{
name: this.addon.name,
}
)}
</p>`
: !this._updating
? html`
${this._changelogContent
? html`
<ha-faded>
<ha-markdown .content=${this._changelogContent}>
</ha-markdown>
</ha-faded>
`
: nothing}
<div class="versions">
<p>
${this.hass.localize(
"ui.panel.config.apps.dashboard.update_available.description",
{
name: this.addon.name,
version: this.addon.version,
newest_version: this.addon.version_latest,
}
)}
</p>
</div>
${createBackupTexts
? html`
<hr />
<ha-row-item>
<span slot="headline">
${createBackupTexts.title}
</span>
${createBackupTexts.description
? html`
<span slot="supporting-text">
${createBackupTexts.description}
</span>
`
: nothing}
<ha-switch slot="end" id="create-backup"></ha-switch>
</ha-row-item>
`
: nothing}
`
: html`<ha-spinner
aria-label="Updating"
size="large"
></ha-spinner>
<p class="progress-text">
${this.hass.localize(
"ui.panel.config.apps.dashboard.update_available.updating",
{
name: this.addon.name,
version: this.addon.version_latest,
}
)}
</p>`}
</div>
${this.addon.version !== this.addon.version_latest && !this._updating
? html`
<div class="card-actions">
<span></span>
<ha-progress-button @click=${this._update}>
${this.hass.localize("ui.common.update")}
</ha-progress-button>
</div>
`
: nothing}
</ha-card>
`;
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._loadAddonData();
}
private _computeCreateBackupTexts():
| { title: string; description?: string }
| undefined {
if (atLeastVersion(this.hass.config.version, 2025, 2, 0)) {
const version = this.addon.version;
return {
title: this.hass.localize(
"ui.panel.config.apps.dashboard.update_available.create_backup.app"
),
description: this.hass.localize(
"ui.panel.config.apps.dashboard.update_available.create_backup.app_description",
{ version: version }
),
};
}
return {
title: this.hass.localize(
"ui.panel.config.apps.dashboard.update_available.create_backup.generic"
),
};
}
get _shouldCreateBackup(): boolean {
const createBackupSwitch = this.shadowRoot?.getElementById(
"create-backup"
) as HaSwitch;
if (createBackupSwitch) {
return createBackupSwitch.checked;
}
return true;
}
private async _loadAddonData() {
if (this.addon.changelog) {
try {
const content = await fetchHassioAddonChangelog(
this.hass,
this.addon.slug
);
this._changelogContent = extractChangelog(
this.addon as HassioAddonDetails,
content
);
} catch (err) {
this._error = extractApiErrorMessage(err);
}
}
}
private async _update() {
this._error = undefined;
this._updating = true;
try {
await updateHassioAddon(
this.hass,
this.addon.slug,
this._shouldCreateBackup
);
} catch (err: any) {
if (this.hass.connection.connected && !ignoreSupervisorError(err)) {
this._error = extractApiErrorMessage(err);
this._updating = false;
return;
}
}
fireEvent(this, "update-complete");
this._updating = false;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
}
ha-card {
margin: auto;
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
.card-actions {
display: flex;
justify-content: space-between;
}
ha-spinner {
display: block;
margin: 32px;
text-align: center;
}
.progress-text {
text-align: center;
}
ha-markdown {
padding-bottom: var(--ha-space-2);
}
hr {
border-color: var(--divider-color);
border-bottom: none;
margin: var(--ha-space-4) 0 0 0;
}
ha-row-item {
--ha-row-item-padding-inline: 0;
margin-bottom: calc(-1 * var(--ha-space-4));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"supervisor-app-update-available-card": SupervisorAppUpdateAvailableCard;
}
}
@@ -183,7 +183,7 @@ class SupervisorAppAudio extends LitElement {
this._selectedOutput === "default" ? null : this._selectedOutput,
};
try {
await setHassioAddonOption(this.hass, this.addon.slug, data);
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
if (this.addon?.state === "started") {
await suggestSupervisorAppRestart(this, this.hass, this.addon);
}
@@ -449,7 +449,7 @@ class SupervisorAppConfig extends LitElement {
options: null,
};
try {
await setHassioAddonOption(this.hass, this.addon.slug, data);
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._configHasChanged = false;
const eventdata = {
success: true,
@@ -488,14 +488,14 @@ class SupervisorAppConfig extends LitElement {
try {
const validation = await validateHassioAddonOption(
this.hass,
this.hass.callWS,
this.addon.slug,
options
);
if (!validation.valid) {
throw Error(validation.message);
}
await setHassioAddonOption(this.hass, this.addon.slug, {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, {
options,
});
@@ -6,9 +6,9 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-card";
import "../../../../../components/ha-formfield";
import "../../../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-formfield";
import type {
HassioAddonDetails,
HassioAddonSetOptionParams,
@@ -17,8 +17,8 @@ import { setHassioAddonOption } from "../../../../../data/hassio/addon";
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
@customElement("supervisor-app-network")
class SupervisorAppNetwork extends LitElement {
@@ -160,7 +160,7 @@ class SupervisorAppNetwork extends LitElement {
};
try {
await setHassioAddonOption(this.hass, this.addon.slug, data);
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._configHasChanged = false;
const eventdata = {
success: true,
@@ -205,7 +205,7 @@ class SupervisorAppNetwork extends LitElement {
};
try {
await setHassioAddonOption(this.hass, this.addon.slug, data);
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._configHasChanged = false;
const eventdata = {
success: true,
@@ -28,7 +28,7 @@ export const suggestSupervisorAppRestart = async (
});
if (confirmed) {
try {
await restartHassioAddon(hass, addon.slug);
await restartHassioAddon(hass.callWS, addon.slug);
} catch (err: any) {
showAlertDialog(element, {
title: hass.localize(
@@ -46,8 +46,8 @@ class SupervisorAppInfoDashboard extends LitElement {
css`
.content {
margin: auto;
padding: var(--ha-space-2);
max-width: 1024px;
padding: var(--ha-space-4);
max-width: 1200px;
}
`,
];
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,19 @@
import { consume, type ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import type { HomeAssistant } from "../../../../../types";
import { internationalizationContext } from "../../../../../data/context";
@customElement("supervisor-app-system-managed")
class SupervisorAppSystemManaged extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private i18n!: ContextType<typeof internationalizationContext>;
@property({ type: Boolean, attribute: "hide-button" }) public hideButton =
false;
@@ -19,18 +22,18 @@ class SupervisorAppSystemManaged extends LitElement {
return html`
<ha-alert
alert-type="warning"
.title=${this.hass.localize(
.title=${this.i18n.localize(
"ui.panel.config.apps.dashboard.system_managed.title"
)}
.narrow=${this.narrow}
>
${this.hass.localize(
${this.i18n.localize(
"ui.panel.config.apps.dashboard.system_managed.description"
)}
${!this.hideButton
? html`
<ha-button slot="action" @click=${this._takeControl}>
${this.hass.localize(
${this.i18n.localize(
"ui.panel.config.apps.dashboard.system_managed.take_control"
)}
</ha-button>
@@ -161,7 +161,7 @@ class HaConfigAppDashboard extends LitElement {
}
try {
this._addon = await fetchHassioAddonInfo(this.hass, slug);
this._addon = await fetchHassioAddonInfo(this.hass.callWS, slug);
} catch (err: any) {
if (repositoryUrl) {
try {
@@ -210,7 +210,7 @@ class HaConfigAppDashboard extends LitElement {
}
await addStoreRepository(this.hass, repositoryUrl);
this._addon = await fetchHassioAddonInfo(this.hass, slug);
this._addon = await fetchHassioAddonInfo(this.hass.callWS, slug);
}
private async _apiCalled(ev): Promise<void> {
@@ -0,0 +1,225 @@
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { consume, type ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import {
mdiPalette,
mdiPlayCircleOutline,
mdiPlaylistCheck,
mdiRobotOutline,
mdiScriptTextOutline,
} from "@mdi/js";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-adaptive-dialog";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import {
areasContext,
internationalizationContext,
} from "../../../data/context";
import type { SceneEntities } from "../../../data/scene";
import { showSceneEditor } from "../../../data/scene";
import {
addToActionHandler,
type AddToActionKey,
} from "../../../dialogs/more-info/add-to";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { AreaAddToDialogParams } from "./show-dialog-area-add-to";
@customElement("dialog-area-add-to")
class DialogAreaAddTo extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: areasContext, subscribe: true })
private _areas!: ContextType<typeof areasContext>;
@state() private _params?: AreaAddToDialogParams;
@state() private _open = false;
public showDialog(params: AreaAddToDialogParams): void {
this._params = params;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-adaptive-dialog
.open=${this._open}
header-title=${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
@closed=${this._dialogClosed}
>
${this._renderOptions()}
</ha-adaptive-dialog>
`;
}
private _renderOptions() {
if (!this._params) {
return nothing;
}
const area = this._areas[this._params.areaId];
const areaName = computeAreaName(area) || this._params.areaId;
return html`
<h3 class="section-header">
${this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
</h3>
<ha-list>
${this._renderActionItem(
"automation_trigger",
mdiRobotOutline,
"ui.dialogs.more_info_control.add_to.actions.automation_trigger",
areaName
)}
${this._renderActionItem(
"automation_condition",
mdiPlaylistCheck,
"ui.dialogs.more_info_control.add_to.actions.automation_condition",
areaName
)}
${this._renderActionItem(
"automation_action",
mdiPlayCircleOutline,
"ui.dialogs.more_info_control.add_to.actions.automation_action",
areaName
)}
</ha-list>
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.script.scripts_heading")}
</h3>
<ha-list>
${this._renderActionItem(
"script_action",
mdiScriptTextOutline,
"ui.dialogs.more_info_control.add_to.actions.script_action",
areaName
)}
</ha-list>
${this._renderSceneSection(areaName)}
`;
}
private _renderSceneSection(areaName: string) {
if (!this._params?.entityIds.length) {
return nothing;
}
return html`
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.scene.scenes_heading")}
</h3>
<ha-list>
<ha-list-item
graphic="icon"
@click=${this._handleCreateScene}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${mdiPalette}></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.scene",
{ target: areaName }
)}
</ha-list-item>
</ha-list>
`;
}
private _renderActionItem(
key: AddToActionKey,
path: string,
translationKey:
| "ui.dialogs.more_info_control.add_to.actions.automation_trigger"
| "ui.dialogs.more_info_control.add_to.actions.automation_condition"
| "ui.dialogs.more_info_control.add_to.actions.automation_action"
| "ui.dialogs.more_info_control.add_to.actions.script_action",
areaName: string
) {
return html`
<ha-list-item
graphic="icon"
data-type=${key}
@click=${this._handleAction}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${path}></ha-svg-icon>
${this._i18n.localize(translationKey, { target: areaName })}
</ha-list-item>
`;
}
private _handleAction(ev: Event) {
if (!this._params) {
return;
}
const key = (ev.currentTarget as HTMLElement).dataset
.type as AddToActionKey;
this.closeDialog();
addToActionHandler(key, { area_id: this._params.areaId });
}
private _handleCreateScene() {
if (!this._params) {
return;
}
const entities: SceneEntities = {};
for (const entityId of this._params.entityIds) {
entities[entityId] = "";
}
this.closeDialog();
showSceneEditor({ entities }, this._params.areaId);
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
.section-header {
padding: var(--ha-space-2) var(--ha-space-4) 0;
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-area-add-to": DialogAreaAddTo;
}
}
+248 -37
View File
@@ -1,6 +1,21 @@
import { consume } from "@lit/context";
import { mdiDelete, mdiDotsVertical, mdiImagePlus, mdiPencil } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket/dist/types";
import {
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiImagePlus,
mdiPalette,
mdiPencil,
mdiPlus,
mdiRobot,
mdiScriptText,
mdiShape,
mdiTools,
} from "@mdi/js";
import type {
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -10,7 +25,7 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { goBack } from "../../../common/navigate";
import { goBack, navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { groupBy } from "../../../common/util/group-by";
@@ -18,11 +33,13 @@ import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-list";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import {
@@ -38,6 +55,7 @@ import {
computeEntityRegistryName,
sortEntityRegistryByName,
} from "../../../data/entity/entity_registry";
import { subscribeLabFeature } from "../../../data/labs";
import type { SceneEntity } from "../../../data/scene";
import type { ScriptEntity } from "../../../data/script";
import type { RelatedResult } from "../../../data/search";
@@ -46,9 +64,15 @@ import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
import "../../logbook/ha-logbook";
import {
loadAreaAddToDialog,
showAreaAddToDialog,
} from "./show-dialog-area-add-to";
import {
loadAreaRegistryDetailDialog,
showAreaRegistryDetailDialog,
@@ -59,8 +83,60 @@ declare interface NameAndEntity<EntityType extends HassEntity> {
entity: EntityType;
}
type AreaQuickLinkKey =
| "devices"
| "entities"
| "helpers"
| "automations"
| "scenes"
| "scripts";
const NAVIGATION_ACTIONS: {
value: string;
path: string;
icon: string;
countKey: AreaQuickLinkKey;
}[] = [
{
value: "navigate-devices",
path: "/config/devices/dashboard",
icon: mdiDevices,
countKey: "devices",
},
{
value: "navigate-entities",
path: "/config/entities",
icon: mdiShape,
countKey: "entities",
},
{
value: "navigate-helpers",
path: "/config/helpers",
icon: mdiTools,
countKey: "helpers",
},
{
value: "navigate-automations",
path: "/config/automation/dashboard",
icon: mdiRobot,
countKey: "automations",
},
{
value: "navigate-scenes",
path: "/config/scene/dashboard",
icon: mdiPalette,
countKey: "scenes",
},
{
value: "navigate-scripts",
path: "/config/script/dashboard",
icon: mdiScriptText,
countKey: "scripts",
},
] as const;
@customElement("ha-config-area-page")
class HaConfigAreaPage extends LitElement {
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public areaId!: string;
@@ -77,6 +153,8 @@ class HaConfigAreaPage extends LitElement {
@state() private _related?: RelatedResult;
@state() private _newTriggersConditions = false;
private _logbookTime = { recent: 86400 };
private _memberships = memoizeOne(
@@ -128,9 +206,35 @@ class HaConfigAreaPage extends LitElement {
.concat(memberships.indirectEntities.map((entry) => entry.entity_id))
);
private _getQuickLinkCounts = memoizeOne(
(
memberships: {
devices: DeviceRegistryEntry[];
entities: EntityRegistryEntry[];
indirectEntities: EntityRegistryEntry[];
},
related?: RelatedResult
) => {
const allEntityIds = this._allEntities(memberships);
const entityIds = related?.entity ?? allEntityIds;
return {
devices: related?.device?.length ?? memberships.devices.length,
entities: entityIds.length,
helpers: entityIds.filter((entityId) =>
isHelperDomain(computeDomain(entityId))
).length,
automations: related?.automation?.length ?? 0,
scenes: related?.scene?.length ?? 0,
scripts: related?.script?.length ?? 0,
};
}
);
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog();
loadAreaAddToDialog();
}
protected updated(changedProps: PropertyValues<this>) {
@@ -140,6 +244,23 @@ class HaConfigAreaPage extends LitElement {
}
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
if (!isComponentLoaded(this.hass!.config, "automation")) {
return [];
}
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersConditions = feature.enabled;
}
),
];
}
protected render() {
if (!this.hass.areas || !this.hass.devices || !this.hass.entities) {
return nothing;
@@ -162,6 +283,10 @@ class HaConfigAreaPage extends LitElement {
this._entityReg
);
const { devices, entities } = memberships;
const quickLinkCounts = this._getQuickLinkCounts(
memberships,
this._related
);
// Pre-compute the entity and device names, so we can sort by them
if (devices) {
@@ -220,6 +345,13 @@ class HaConfigAreaPage extends LitElement {
));
}
const nonAutomatedEntities = entities.filter(
(entity) =>
!["scene", "script", "automation"].includes(
computeDomain(entity.entity_id)
)
);
return html`
<hass-subpage
.hass=${this.hass}
@@ -238,6 +370,21 @@ class HaConfigAreaPage extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
${NAVIGATION_ACTIONS.map(
(action) => html`
<ha-dropdown-item value=${action.value}>
<ha-svg-icon slot="icon" .path=${action.icon}></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.areas.quick_links.${action.countKey}`,
{ count: quickLinkCounts[action.countKey] }
)}
<ha-icon-next slot="details"></ha-icon-next>
</ha-dropdown-item>
`
)}
<wa-divider></wa-divider>
<ha-dropdown-item value="edit" .data=${area}>
<ha-svg-icon slot="icon" .path=${mdiPencil}> </ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.edit_settings")}
@@ -264,15 +411,41 @@ class HaConfigAreaPage extends LitElement {
class="img-edit-btn"
></ha-icon-button>
</div>`
: html`<ha-button
appearance="filled"
size="small"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon
.path=${mdiImagePlus}
slot="start"
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.areas.add_picture"
)}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon
slot="start"
.path=${mdiPlus}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
</ha-button>`
: nothing}
</div>`}
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
@@ -303,23 +476,19 @@ class HaConfigAreaPage extends LitElement {
"ui.panel.config.areas.editor.linked_entities_caption"
)}
>
${entities.length
${nonAutomatedEntities.length
? html`<ha-list>
${entities.map((entity) =>
["scene", "script", "automation"].includes(
computeDomain(entity.entity_id)
)
? ""
: html`
<ha-list-item
@click=${this._openEntity}
.entity=${entity}
hasMeta
>
<span>${entity.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
${nonAutomatedEntities.map(
(entity) => html`
<ha-list-item
@click=${this._openEntity}
.entity=${entity}
hasMeta
>
<span>${entity.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
)}</ha-list
>`
: html`
@@ -609,9 +778,39 @@ class HaConfigAreaPage extends LitElement {
this._related = await findRelated(this.hass, "area", this.areaId);
}
private _handleMenuAction(ev: HaDropdownSelectEvent) {
private _showAddToDialog() {
const area = this.hass.areas[this.areaId];
if (!area) {
return;
}
showAreaAddToDialog(this, {
areaId: area.area_id,
entityIds: this._areaEntityIds,
});
}
private get _areaEntityIds(): string[] {
const memberships = this._memberships(
this.areaId,
Object.values(this.hass.devices),
this._entityReg
);
return this._allEntities(memberships);
}
private _handleMenuAction(
ev: HaDropdownSelectEvent<string, AreaRegistryEntry>
) {
const action = ev.detail?.item?.value;
const entry = (ev.detail?.item as any)?.data as AreaRegistryEntry;
const entry = ev.detail?.item?.data;
const navAction = NAVIGATION_ACTIONS.find((a) => a.value === action);
if (navAction) {
navigate(`${navAction.path}?historyBack=1&area=${this.areaId}`);
return;
}
switch (action) {
case "edit":
this._openDialog(entry);
@@ -622,15 +821,19 @@ class HaConfigAreaPage extends LitElement {
}
}
private _showSettings(ev: MouseEvent) {
const entry: AreaRegistryEntry = (ev.currentTarget! as any).entry;
this._openDialog(entry);
private _showSettings(
ev: HASSDomCurrentTargetEvent<
HTMLButtonElement & { entry: AreaRegistryEntry }
>
) {
this._openDialog(ev.currentTarget.entry);
}
private _openEntity(ev) {
const entry: EntityRegistryEntry = (ev.currentTarget as any).entity;
private _openEntity(
ev: HASSDomCurrentTargetEvent<HTMLElement & { entity: EntityRegistryEntry }>
) {
showMoreInfoDialog(this, {
entityId: entry.entity_id,
entityId: ev.currentTarget.entity.entity_id,
});
}
@@ -672,6 +875,14 @@ class HaConfigAreaPage extends LitElement {
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
.action-buttons {
display: flex;
gap: var(--ha-space-2);
flex-wrap: wrap;
justify-content: space-around;
}
img {
border-radius: var(
--ha-card-border-radius,

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