Compare commits

..

87 Commits

Author SHA1 Message Date
Simon Lamon
aef3cb1c36 Merge branch 'dev' into sec_pypi_publishing 2025-11-20 09:53:07 +01:00
hanwg
60229ceba0 Add markdown to parameter descriptions for actions (#27944)
add markdown to parameter descriptions for actions
2025-11-20 08:39:27 +02:00
Paul Bottein
e45b631e27 Allow to reorder areas and floors (#27986)
* Add websocket commands

* Add area reordering

* Reorder floors

* Order areas and floors everywhere

* Use right area order in area floor picker

* Add error handling

* Refactor
2025-11-19 16:01:02 +01:00
Petar Petrov
ea798cda90 Bump glob to 12.0.0 (#27999) 2025-11-19 15:18:15 +01:00
Petar Petrov
259f4421db Add detail option to trend card feature (#27993) 2025-11-19 15:17:03 +01:00
Paul Bottein
1ac3cf199f Save default panel at user and system level (#27899)
* Save default panel in user data

* Change logic for default panel

* Fix types

* Fix typings

* Fix user and local storage

* Use user data and system data

* Update url path and update dashboard settings

* Fix tests

* Wait for panels and user/system data to be loaded

* Update comment

* Update comment

* Set empty object instead of null

* Update comment

* Feedbacks

* Apply suggestions from code review

* format

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-19 15:03:20 +01:00
Aidan Timson
8d96679cc3 Swap to margins for narrow safe areas in ha-md-dialog (#27994)
Swap to margins for narrow layouts and safe areas
2025-11-19 15:56:10 +02:00
karwosts
ba9c7f3012 Expose location for calendar events (#27983)
* Expose location for calendar events

* from review
2025-11-19 15:40:19 +02:00
Petar Petrov
f8923ed648 Split Energy panel into overview and electricity view (#27534) 2025-11-19 14:11:27 +01:00
Petar Petrov
20d0548d33 Add min/max options to bar gauge feature (#27933) 2025-11-19 11:20:06 +01:00
Aidan Timson
04aaae20f5 Create keyboard shortcuts helper (tinykeys) (#27176) 2025-11-19 11:19:01 +01:00
karwosts
852dbbeee0 Fix keyboard for integration page overflow menus (#27991) 2025-11-19 08:44:51 +02:00
Ezra Freedman
d57367f62e Fix selection state not updating after item deletion (#27972)
* Fix selection state not updating after item deletion

* only update selected list if script is found
2025-11-19 08:20:12 +02:00
Petar Petrov
47a107dd85 Add Power Sankey card (#27966) 2025-11-18 19:52:52 +01:00
renovate[bot]
3573e823e4 Update Yarn to v4.11.0 (#27978)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 17:05:36 +00:00
Ingolf Becker
1a8319a3ab Create mobile column gap variable in hui-sections-view (#27949)
Update mobile column gap variable in hui-sections-view
2025-11-18 14:56:28 +02:00
dependabot[bot]
ac23ce6300 Bump glob from 11.0.3 to 11.1.0 (#27976)
Bumps [glob](https://github.com/isaacs/node-glob) from 11.0.3 to 11.1.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v11.0.3...v11.1.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 11.1.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-18 06:27:19 +01:00
karwosts
fc38365958 Fix keyboard for ha-date-input (#27968)
* Fix keyboard for ha-date-input

* Update src/components/ha-date-input.ts

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-17 14:22:36 +00:00
koostamas
b93bf3bc4b Remote stream playing in picture-in-picture fix (#27958)
* Remote stream playing in picture-in-picture fix

* Update src/components/ha-hls-player.ts

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

* Update src/components/ha-web-rtc-player.ts

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-17 15:44:24 +02:00
Raad Altaie
869ab6ffc4 disable browser autofill on search inputs (#27963) 2025-11-17 12:58:40 +00:00
dependabot[bot]
effba9b918 Bump github/codeql-action from 4.31.2 to 4.31.3 (#27965) 2025-11-17 07:19:27 +01:00
Yosi Levy
c848673b1f Various RTL fixes (#27886) 2025-11-16 14:42:09 +02:00
renovate[bot]
074095d3dc Update dependency js-yaml to v4.1.1 [SECURITY] (#27955)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-15 15:42:16 +02:00
Aidan Timson
1bd1e015ff Migrate dialog-lovelace-resource-detail to ha-wa-dialog (#27939) 2025-11-14 08:32:41 +02:00
Aidan Timson
7588490419 Migrate dialog-config-entry-system-options to ha-wa-dialog (#27938) 2025-11-14 08:27:17 +02:00
Petar Petrov
2e80a3ddab Add configurable chart modes in energy devices graph card (#27937) 2025-11-14 08:16:36 +02:00
Bram Kragten
332694549c Add support for triggers.yaml (#27379) 2025-11-13 23:31:40 +01:00
karwosts
396ddef722 Expose completed timestamp for TodoItem (#27943) 2025-11-13 22:40:56 +01:00
Aidan Timson
d02804449a Merge media selectors for index.html.template (#27941) 2025-11-13 22:33:30 +01:00
Simon Lamon
4ab24cdc72 Rspack: Deprecated layers (#27942) 2025-11-13 22:32:37 +01:00
Aidan Timson
81c27090d2 Create withViewTransition wrapper function (#27918)
* Create withViewTransition wrapper function

* Add missing space

* Remove function, check for view transition, add param

* Document
2025-11-13 17:32:15 +02:00
karwosts
09bdfd3ad7 Fix incorrect (Disabled) string in trigger (#27935) 2025-11-13 14:36:05 +00:00
karwosts
97e49f751c Fix media image on dashboard-level background (#27934) 2025-11-13 15:43:27 +02:00
Aidan Timson
e0d241a2db Move unimplemented base animations to theme styles (#27920) 2025-11-13 15:37:13 +02:00
Petar Petrov
83e065ae98 Power sources chart (#27501)
* Add power configuration to Energy dashboard

* update translation

* Update src/translations/en.json

Co-authored-by: Norbert Rittel <norbert@rittel.de>

* Update src/panels/config/energy/dialogs/dialog-energy-grid-flow-settings.ts

Co-authored-by: Aidan Timson <aidan@timmo.dev>

* Power graph card

* Single stat for bidirectional power

* Rename power graph to power sources graph

* remove debug code

* tweak

* update translations

* remove unused code

* Separate grid power from energy

* update translation

* update translation

* update data format

* Apply suggestions from code review

Co-authored-by: Aidan Timson <aidan@timmo.dev>

* Renamed stat_power to stat_rate

* translation tweak

* rename to stat_rate

* Add a line depicting used power

* Typescript improvements

* Add comment

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2025-11-13 09:26:49 +00:00
Simon Lamon
8535ee0694 Merge branch 'dev' into sec_pypi_publishing 2025-11-13 06:58:30 +01:00
Paulus Schoutsen
a6ee670682 Fix bad minification (#27926) 2025-11-12 23:29:58 -05:00
Wendelin
c457f92826 Upgrade WA to 3.0.0 (#27919) 2025-11-12 17:39:56 +01:00
karwosts
c73bd96a1f Fix fields without selectors (#27917) 2025-11-12 17:26:48 +02:00
Wendelin
711f8e2fc3 Fix ha-dropdown and add shadow tokens (#27916)
* Fix dropdown select handle in refresh tokens

* Use semantic shadows for dropdown

* Fix token names
2025-11-12 16:52:05 +02:00
Aidan Timson
91a0066544 Add dashboard time visibility condition (#27790)
* Add time-based conditional visibility for cards

* Move clearTimeout outside of scheduleUpdate

* Add time string validation

* Add time string validation

* Remove runtime validation as config shouldnt allow bad values

* Fix for midnight crossing

* Cap timeout to 32-bit signed integer

* Add listener tests

* Additional tests

* Format
2025-11-12 15:55:59 +02:00
Aidan Timson
aee7b8b8d4 Setup base animation styles, add fade out to launch screen (#27829)
* Setup base animation styles

* Add fade out to launch screen

* Cleanup

* Set opacity before removing element

* Remove

* Final

* Use computed duration for timeout

* Add skip animation prop

* Swap

* Use common function and fix issue
2025-11-12 11:54:53 +02:00
Aidan Timson
d38d770e1a Refactor ConditionalListenerMixin and extract shared utilities (#27858)
* Refactor ConditionalListenerMixin and extract shared utilities

* Remove

* Use proper type

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

* Import

* Fix typing

* Docstrings

* Use generic types and refactor visibility handling

* Fix function signature and handle other keys separately

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-12 09:50:38 +00:00
Petar Petrov
0036679553 Fix target picker displaying blank (#27910) 2025-11-12 09:49:37 +01:00
Simon Lamon
2b85108242 Introduce ha-dropdown (#27417)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: Wendelin <w@pe8.at>
2025-11-12 09:23:31 +01:00
Petar Petrov
c74320cb82 Add power configuration to Energy dashboard (#27373)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2025-11-12 09:21:52 +01:00
renovate[bot]
8ebe6e24d2 Update dependency marked to v17 (#27885)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-12 09:24:17 +02:00
Paul Bottein
d3182da587 Create dedicated panel for home dashboard (#27861)
* Create dedicated panel for home dashboard

* Don't use state if only one view

* Prettier

* Use hui-root

* Add alert for edit mode

* Remove no edit

* Add home panel to dashboard list
2025-11-12 09:09:46 +02:00
Petar Petrov
41fbc5e44b Increase ZHA reconfiguration dialog width for details view (#27909) 2025-11-11 20:07:10 +01:00
Tobias Bieniek
3fea41eb0e Add scenes category to home dashboard area views (#27712)
This is similar to the "Automations" category that was added in 52eb3d8, but for "Scenes" this time. It is positioned after the other summary sections.
2025-11-11 11:19:02 +02:00
ildar170975
9a627bdea7 Data tables: remove unneeded "direction: asc" lines (#27903)
* remove unneeded "direction: asc"

* remove unneeded "direction: asc"

* remove unneeded "direction: asc"
2025-11-11 08:14:03 +02:00
Norbert Rittel
d9a67f603d Fix grammar in new_automation_setup_failed_text (#27898)
* Fix grammar in `new_automation_setup_failed_text`

* Remove excessive comma
2025-11-10 18:48:26 +01:00
Wendelin
5f37f8c0ab Use generic picker for target-picker (#27850)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-10 17:15:16 +01:00
Petar Petrov
f222702abf Fix entity name in statistics chart (#27896) 2025-11-10 15:08:33 +01:00
Copilot
2107b7c267 Fix doubled tooltips on timeline charts for mobile devices (#27888)
* Initial plan

* Fix doubled date popups in timeline charts on mobile

Co-authored-by: MindFreeze <5219205+MindFreeze@users.noreply.github.com>

* Add comment explaining triggerTooltip fix

* Actual fix

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: MindFreeze <5219205+MindFreeze@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-10 14:31:30 +01:00
Petar Petrov
1c05afebd7 Smooth sensor card more when "Show more detail" is disabled (#27891)
* Smooth sensor card more when "Show more detail" is disabled

* Set minimum sample points to 10
2025-11-10 14:23:26 +01:00
Paul Bottein
7179bb2d26 Assume default visible true for panels (#27894) 2025-11-10 15:04:14 +02:00
Wendelin
95cf1fdcf7 Fix target picker for entity_id: none (#27893)
Fix notFound condition to exclude 'none' in ha-target-picker-item-row
2025-11-10 12:23:16 +00:00
renovate[bot]
9617956cc6 Update vitest monorepo to v4.0.8 (#27892)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 14:15:36 +02:00
karwosts
65df464731 Fix entity editor with non-existant entity (#27875) 2025-11-10 14:09:44 +02:00
Wendelin
bd4e9a3d05 Use ha-ripple in ha-md-list-item (#27889) 2025-11-10 14:04:30 +02:00
ildar170975
963fc13a99 relative_time: increase thresholds (#27870)
* increase thresholds

* restored for days & hours
2025-11-10 12:58:13 +02:00
renovate[bot]
ff614918d4 Update vaadinWebComponents monorepo to v24.9.5 (#27884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 12:55:03 +02:00
ildar170975
48aa5fb970 hui-generic-entity-row: add tooltips for relative-time (#27871)
* add tooltips for relative-rime

* lint

* fix import

* prettier

* move a call of uid() to a private property

* some test change
2025-11-10 12:54:26 +02:00
ildar170975
190af65756 Display tooltips for labels (#27613)
* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description for ha-label

* add ha-tooltip for ha-input-chip

* add ha-tooltip

* replace() -> replaceAll()

* replace() -> replaceAll()

* prettier

* fix styles to enlarge an "active tooltip area"

* additional check for null for "description"

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* call uid() in constructor

* fix a check for null

* attempting to bypass insecure randomness

* move a call of uid() into constructor

* uid generation tweak

* Apply suggestions from code review

* prettier

* simplify

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-10 10:04:31 +00:00
Petar Petrov
76911e0e0d Dynamic total energy for pie chart (#27883) 2025-11-10 10:20:26 +01:00
Petar Petrov
b8ec7c2e72 Fix chart label outline color (#27882) 2025-11-10 08:53:18 +01:00
dependabot[bot]
10e20c2272 Bump softprops/action-gh-release from 2.4.1 to 2.4.2 (#27879) 2025-11-09 22:14:19 -08:00
dependabot[bot]
2ec05aac2f Bump relative-ci/agent-action from 3.1.0 to 3.2.0 (#27880) 2025-11-09 22:12:30 -08:00
renovate[bot]
61fbe5b53c Update dependency marked to v16.4.2 (#27877)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 06:57:39 +01:00
Yuksel Beyti
5f3f1c7139 Fix malformed HTML tags in backup backups component (#27872) 2025-11-09 13:56:43 +02:00
ildar170975
48f37b1b1e "Expand" tooltips: remove a trailing dot (#27869)
remove trailing dot
2025-11-09 09:00:28 +01:00
karwosts
9091df9db5 Fix sequence action copy-paste (#27652) 2025-11-08 15:50:38 +01:00
renovate[bot]
1cd7a1cd78 Update dependency @rsdoctor/rspack-plugin to v1.3.8 (#27867)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:48:08 +01:00
renovate[bot]
ae5c7026b9 Update dependency @rspack/core to v1.6.1 (#27864)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:16:38 +01:00
renovate[bot]
3900f3995a Update vitest monorepo to v4.0.7 (#27862)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:16:16 +01:00
Paul Bottein
237f974ee8 Only show panel with default visible flag in sidebar (#27838) 2025-11-08 13:15:43 +01:00
Paul Bottein
b2ec4b7d2c Fix backup download and delete actions (#27851) 2025-11-07 13:43:00 +02:00
Aidan Timson
b4c83d7877 Migrate dialog-repairs-issue to ha-wa-dialog (#27667)
* Migrate dialog-repairs-issue to ha-wa-dialog

* Allow for custom slots for header title and subtitle, using boolean to enable

* Fix typing

* Cleanup

* Refactor header slot logic

* [workaround] pass aria attributes to dialog

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

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

* Remove unused (by wa) aira attrs

* Fix imports

* Revert "Remove unused (by wa) aira attrs"

This reverts commit ce97bebce4.

* Remove workaround

* Remove

* Format

* Fix subtitle margin

* Use spacing token

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-07 13:42:07 +02:00
Paul Bottein
22c53b6859 Add area context in zha, zwave and bluetooth graph (#27849)
* Add area context in zha, zwave and bluetooth graph

* Fix undefined device

* Fix typing

* Add context to find area
2025-11-07 13:34:44 +02:00
Paul Bottein
750b1e5e16 Avoid cropping in base graph (#27848)
* Avoid cropping in base graph

* Add bottom margin
2025-11-07 11:25:35 +01:00
Simon Lamon
b8110d1a45 Merge branch 'dev' into sec_pypi_publishing 2025-10-27 06:41:51 +01:00
Simon Lamon
19e9de39c5 Merge branch 'dev' into sec_pypi_publishing 2025-10-19 10:56:12 +02:00
Simon Lamon
f22f01e513 Merge branch 'dev' into sec_pypi_publishing 2025-10-06 20:28:38 +02:00
Simon Lamon
3f86f144b5 Merge branch 'dev' into sec_pypi_publishing 2025-10-04 17:25:20 +02:00
Simon Lamon
4efef5ed16 Update release.yaml 2025-09-24 07:04:06 +02:00
Simon Lamon
cac7ae2a40 Remove twine and introduce trusted publishing 2025-09-20 21:23:04 +02:00
227 changed files with 10014 additions and 5170 deletions

View File

@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@8504826a02078b05756e4c07e380023cc2c4274a # v3.1.0
uses: relative-ci/agent-action@feb19ddc698445db27401f1490f6ac182da0816f # v3.2.0
with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
token: ${{ github.token }}

View File

@@ -19,8 +19,11 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: write # Required to upload release assets
id-token: write # For "Trusted Publisher" to PyPi
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -46,16 +49,20 @@ jobs:
run: ./script/translations_download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
- name: Build and release package
run: |
python3 -m pip install twine build
export TWINE_USERNAME="__token__"
export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}"
python3 -m pip install build
export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1
script/release
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
- name: Upload release assets
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
files: |
dist/*.whl
@@ -108,7 +115,7 @@ jobs:
- name: Tar folder
run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist .
- name: Upload release asset
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
@@ -137,6 +144,6 @@ jobs:
- name: Tar folder
run: tar -czf hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz -C hassio/build .
- name: Upload release asset
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
files: hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz

1
.gitignore vendored
View File

@@ -5,7 +5,6 @@
build/
dist/
/hass_frontend/
/logs/dist/
/translations/
# yarn

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.10.3.cjs
yarnPath: .yarn/releases/yarn-4.11.0.cjs

View File

@@ -327,20 +327,6 @@ module.exports.config = {
};
},
logs({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "logs" + nameSuffix(latestBuild),
entry: {
entrypoint: path.resolve(paths.logs_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.logs_output_root, latestBuild),
publicPath: publicPath(latestBuild),
isProdBuild,
latestBuild,
isStatsBuild,
};
},
landingPage({ isProdBuild, latestBuild }) {
return {
name: "landing-page" + nameSuffix(latestBuild),

View File

@@ -39,13 +39,6 @@ gulp.task(
)
);
gulp.task(
"clean-logs",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.logs_output_root, paths.build_dir])
)
);
gulp.task(
"clean-landing-page",
gulp.parallel("clean-translations", async () =>

View File

@@ -245,24 +245,6 @@ gulp.task(
)
);
const LOGS_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
gulp.task(
"gen-pages-logs-dev",
genPagesDevTask(LOGS_PAGE_ENTRIES, paths.logs_dir, paths.logs_output_root)
);
gulp.task(
"gen-pages-logs-prod",
genPagesProdTask(
LOGS_PAGE_ENTRIES,
paths.logs_dir,
paths.logs_output_root,
paths.logs_output_latest,
paths.logs_output_es5
)
);
const LANDING_PAGE_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
gulp.task(

View File

@@ -202,16 +202,6 @@ gulp.task("copy-static-gallery", async () => {
copyMdiIcons(paths.gallery_output_static);
});
gulp.task("copy-static-logs", async () => {
// Copy app static files
fs.copySync(polyPath("public/static"), paths.logs_output_static);
copyFonts(paths.logs_output_static);
copyTranslations(paths.logs_output_static);
copyLocaleData(paths.logs_output_static);
copyMdiIcons(paths.logs_output_static);
});
gulp.task("copy-static-landing-page", async () => {
// Copy landing-page static files
fs.copySync(

View File

@@ -7,7 +7,6 @@ import "./download-translations.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
import "./logs.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./hassio.js";

View File

@@ -1,39 +0,0 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-logs",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-logs",
gulp.parallel(
"gen-icons-json",
"gen-pages-logs-dev",
"build-translations",
"build-locale-data"
),
"copy-static-logs",
"rspack-dev-server-logs"
)
);
gulp.task(
"build-logs",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-logs",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-logs",
"rspack-prod-logs",
"gen-pages-logs-prod"
)
);

View File

@@ -15,7 +15,6 @@ import {
createGalleryConfig,
createHassioConfig,
createLandingPageConfig,
createLogsConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -205,25 +204,6 @@ gulp.task("rspack-prod-gallery", () =>
)
);
gulp.task("rspack-dev-server-logs", () =>
runDevServer({
compiler: rspack(
createLogsConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.logs_output_root,
port: 5647,
})
);
gulp.task("rspack-prod-logs", () =>
prodBuild(
bothBuilds(createLogsConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
gulp.task("rspack-watch-landing-page", () => {
// This command will run forever because we don't close compiler
rspack(

View File

@@ -59,11 +59,5 @@ module.exports = {
hassio_output_es5: path.resolve(__dirname, "../hassio/build/frontend_es5"),
hassio_publicPath: "/api/hassio/app",
logs_dir: path.resolve(__dirname, "../logs"),
logs_output_root: path.resolve(__dirname, "../logs/dist"),
logs_output_static: path.resolve(__dirname, "../logs/dist/static"),
logs_output_latest: path.resolve(__dirname, "../logs/dist/frontend_latest"),
logs_output_es5: path.resolve(__dirname, "../logs/dist/frontend_es5"),
translations_src: path.resolve(__dirname, "../src/translations"),
};

View File

@@ -260,7 +260,6 @@ const createRspackConfig = ({
),
},
experiments: {
layers: true,
outputModule: true,
},
};
@@ -302,11 +301,6 @@ const createHassioConfig = ({
const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
const createLogsConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.logs({ isProdBuild, latestBuild, isStatsBuild })
);
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
@@ -316,7 +310,6 @@ module.exports = {
createCastConfig,
createHassioConfig,
createGalleryConfig,
createLogsConfig,
createRspackConfig,
createLandingPageConfig,
};

View File

@@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-yaml-editor";
import type { Trigger } from "../../../../src/data/automation";
import type { LegacyTrigger } from "../../../../src/data/automation";
import { describeTrigger } from "../../../../src/data/automation_i18n";
import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass";
@@ -66,7 +66,7 @@ const triggers = [
},
];
const initialTrigger: Trigger = {
const initialTrigger: LegacyTrigger = {
trigger: "state",
entity_id: "light.kitchen",
};

View File

@@ -0,0 +1,55 @@
---
title: Dropdown
---
# Dropdown `<ha-dropdown>`
## Implementation
A compact, accessible dropdown menu for choosing actions or settings. `ha-dropdown` supports composed menu items (`<ha-dropdown-item>`) for icons, submenus, checkboxes, disabled entries, and destructive variants. Use composition with `slot="trigger"` to control the trigger button and use `<ha-dropdown-item>` for rich item content.
### Example usage (composition)
```html
<ha-dropdown open>
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
<ha-dropdown-item>
<ha-svg-icon .path="mdiContentCut" slot="icon"></ha-svg-icon>
Cut
</ha-dropdown-item>
<ha-dropdown-item>
<ha-svg-icon .path="mdiContentCopy" slot="icon"></ha-svg-icon>
Copy
</ha-dropdown-item>
<ha-dropdown-item disabled>
<ha-svg-icon .path="mdiContentPaste" slot="icon"></ha-svg-icon>
Paste
</ha-dropdown-item>
<ha-dropdown-item>
Show images
<ha-dropdown-item slot="submenu" value="show-all-images"
>Show all images</ha-dropdown-item
>
<ha-dropdown-item slot="submenu" value="show-thumbnails"
>Show thumbnails</ha-dropdown-item
>
</ha-dropdown-item>
<ha-dropdown-item type="checkbox" checked>Emoji shortcuts</ha-dropdown-item>
<ha-dropdown-item type="checkbox" checked>Word wrap</ha-dropdown-item>
<ha-dropdown-item variant="danger">
<ha-svg-icon .path="mdiDelete" slot="icon"></ha-svg-icon>
Delete
</ha-dropdown-item>
</ha-dropdown>
```
### API
This component is based on the webawesome dropdown component.
Check the [webawesome documentation](https://webawesome.com/docs/components/dropdown/) for more details.

View File

@@ -0,0 +1,133 @@
import "@home-assistant/webawesome/dist/components/button/button";
import "@home-assistant/webawesome/dist/components/dropdown/dropdown";
import "@home-assistant/webawesome/dist/components/icon/icon";
import "@home-assistant/webawesome/dist/components/popup/popup";
import {
mdiContentCopy,
mdiContentCut,
mdiContentPaste,
mdiDelete,
} from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-dropdown";
import "../../../../src/components/ha-dropdown-item";
import "../../../../src/components/ha-icon-button";
import "../../../../src/components/ha-svg-icon";
@customElement("demo-components-ha-dropdown")
export class DemoHaDropdown extends LitElement {
protected render(): TemplateResult {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-button in ${mode}">
<div class="card-content">
<ha-dropdown open>
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
<ha-dropdown-item>
<ha-svg-icon
.path=${mdiContentCut}
slot="icon"
></ha-svg-icon>
Cut
</ha-dropdown-item>
<ha-dropdown-item>
<ha-svg-icon
.path=${mdiContentCopy}
slot="icon"
></ha-svg-icon>
Copy
</ha-dropdown-item>
<ha-dropdown-item disabled>
<ha-svg-icon
.path=${mdiContentPaste}
slot="icon"
></ha-svg-icon>
Paste
</ha-dropdown-item>
<ha-dropdown-item>
Show images
<ha-dropdown-item slot="submenu" value="show-all-images"
>Show All Images</ha-dropdown-item
>
<ha-dropdown-item slot="submenu" value="show-thumbnails"
>Show Thumbnails</ha-dropdown-item
>
</ha-dropdown-item>
<ha-dropdown-item type="checkbox" checked
>Emoji Shortcuts</ha-dropdown-item
>
<ha-dropdown-item type="checkbox" checked
>Word Wrap</ha-dropdown-item
>
<ha-dropdown-item variant="danger">
<ha-svg-icon .path=${mdiDelete} slot="icon"></ha-svg-icon>
Delete
</ha-dropdown-item>
</ha-dropdown>
</div>
</ha-card>
</div>
`
)}
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
.button {
padding: unset;
}
ha-card {
margin: 24px auto;
}
.card-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.card-content div {
display: flex;
gap: 8px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-dropdown": DemoHaDropdown;
}
}

View File

@@ -1,9 +0,0 @@
dist/
src/
node_modules/
*.md
.git/
.gitignore
docker-compose.yaml
backend/ha-logs-proxy
backend/README.md

View File

@@ -1,47 +0,0 @@
ARG BUILD_FROM
FROM $BUILD_FROM AS base
# Install dependencies
RUN apk add --no-cache \
bash \
jq \
curl \
go
# Install Home Assistant CLI
ARG BUILD_ARCH
ARG CLI_VERSION
RUN curl -Lso /usr/bin/ha \
"https://github.com/home-assistant/cli/releases/download/${CLI_VERSION}/ha_${BUILD_ARCH}" \
&& chmod a+x /usr/bin/ha
# Build Go backend
WORKDIR /app
COPY backend/go.mod backend/go.sum* ./
RUN go mod download || true
COPY backend/*.go ./
RUN CGO_ENABLED=0 go build -o ha-logs-proxy .
# Final stage
FROM $BUILD_FROM
# Install runtime dependencies
RUN apk add --no-cache \
bash \
jq \
curl
# Copy HA CLI from base
COPY --from=base /usr/bin/ha /usr/bin/ha
# Copy Go backend
COPY --from=base /app/ha-logs-proxy /usr/bin/ha-logs-proxy
WORKDIR /root
# Expose port for backend (5642 = LOGB)
EXPOSE 5642
# Default command
CMD ["/usr/bin/ha-logs-proxy"]

View File

@@ -1,113 +0,0 @@
# Home Assistant CLI Docker Container
A simple multi-architecture Docker container with the Home Assistant CLI installed.
## Development Usage
The CLI container is integrated into the `script/develop-logs` workflow. Both flags are required:
```bash
# Start dev server with CLI container (requires remote_api add-on)
script/develop-logs -c http://192.168.1.2 -t your_token_here
```
When started with credentials, the container runs a Go backend that proxies HA CLI logs commands. The backend API is available at `http://localhost:5642`.
**Frontend Features:**
- Dropdown menu to select log provider (core, supervisor, host, audio, dns, multicast)
- Follow mode with WebSocket streaming (`ha core logs --follow`)
- Manual refresh to fetch latest logs
- Download logs as text file
- Line wrapping toggle
- Auto-scroll to bottom when following
- Error display with retry
**API Endpoints:**
```bash
# List all endpoints
curl http://localhost:5642/api/logs
# Get static logs
curl http://localhost:5642/api/logs/core
curl http://localhost:5642/api/logs/supervisor
# Health check
curl http://localhost:5642/health
# WebSocket streaming (requires websocat or browser)
websocat ws://localhost:5642/api/logs/core/follow
```
You can also execute HA CLI commands directly:
```bash
docker exec -it ha-cli-dev ha info
docker exec -it ha-cli-dev ha supervisor info
```
Stop everything with Ctrl+C (both the dev server and backend will stop automatically).
### Getting API Token
1. Install the [remote_api add-on](https://github.com/home-assistant/addons/tree/master/remote_api) in Home Assistant
2. Check the add-on logs for the generated token
3. Use the token with the `-t` flag
## Build
### Local Build (Single Architecture)
```bash
docker build \
--build-arg BUILD_FROM=alpine:3.22 \
--build-arg BUILD_ARCH=amd64 \
--build-arg CLI_VERSION=4.42.0 \
-t ha-cli:local \
.
```
### Multi-Architecture Build
The `build.yaml` configuration is designed for use with Home Assistant's build system. For local multi-arch builds, use Docker Buildx:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg BUILD_FROM=alpine:3.22 \
--build-arg CLI_VERSION=4.42.0 \
-t ha-cli:latest \
.
```
## Usage
### Run CLI Commands
```bash
docker run --rm ha-cli:local ha help
docker run --rm ha-cli:local ha supervisor info
```
### Interactive Shell
```bash
docker run -it --rm ha-cli:local
# Then run: ha <command>
```
### With Docker Compose
```bash
docker compose run --rm ha-cli ha help
```
## Update CLI Version
Edit the `CLI_VERSION` in `build.yaml` or pass it as a build argument:
```bash
docker build --build-arg CLI_VERSION=4.43.0 ...
```
Check for latest versions at: https://github.com/home-assistant/cli/releases

View File

@@ -1 +0,0 @@
ha-logs-proxy

View File

@@ -1,108 +0,0 @@
# HA Logs Proxy Backend
A Go backend that proxies Home Assistant CLI logs commands through a secure HTTP API.
## Features
- Only allows `logs` commands (security-restricted)
- GET endpoints for static logs
- WebSocket endpoints for streaming logs (follow mode)
- CORS enabled for frontend integration
- Simple JSON API
## API Endpoints
### GET /api/logs
List all available endpoints.
### GET /api/logs/core
Get Home Assistant core logs.
### GET /api/logs/supervisor
Get Supervisor logs.
### GET /api/logs/host
Get host system logs.
### GET /api/logs/audio
Get audio logs.
### GET /api/logs/dns
Get DNS logs.
### GET /api/logs/multicast
Get multicast logs.
### GET /health
Health check endpoint.
**Response format (all log endpoints):**
```json
{
"output": "log content here...",
"error": "error message if any"
}
```
### WS /api/logs/*/follow
WebSocket endpoints for streaming logs in real-time.
Available endpoints:
- `WS /api/logs/core/follow` - Stream core logs
- `WS /api/logs/supervisor/follow` - Stream supervisor logs
- `WS /api/logs/host/follow` - Stream host logs
- `WS /api/logs/audio/follow` - Stream audio logs
- `WS /api/logs/dns/follow` - Stream DNS logs
- `WS /api/logs/multicast/follow` - Stream multicast logs
Each WebSocket message contains a single log line as plain text. The connection streams output from `ha {component} logs --follow` command.
## Running Locally
```bash
cd backend
go run main.go
```
The server starts on port 5642 (LOGB) by default. Override with `PORT` environment variable:
```bash
PORT=3000 go run main.go
```
## Building
```bash
go build -o ha-logs-proxy
./ha-logs-proxy
```
## Testing
```bash
# List endpoints
curl http://localhost:5642/api/logs
# Get core logs
curl http://localhost:5642/api/logs/core
# Get supervisor logs
curl http://localhost:5642/api/logs/supervisor
# Health check
curl http://localhost:5642/health
```
## Docker Integration
The backend is designed to run in the same container as the HA CLI, sharing access to the `ha` command.

View File

@@ -1,5 +0,0 @@
module github.com/home-assistant/frontend/logs/backend
go 1.24
require github.com/gorilla/websocket v1.5.3

View File

@@ -1,2 +0,0 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

View File

@@ -1,372 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"log"
"net/http"
"os"
"os/exec"
"github.com/gorilla/websocket"
)
type LogsResponse struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins (CORS)
},
}
func executeHACommand(args []string) (string, error) {
cmd := exec.Command("ha", args...)
output, err := cmd.CombinedOutput()
return string(output), err
}
func streamHACommandToWS(conn *websocket.Conn, args []string) error {
cmd := exec.Command("ha", args...)
// Get stdout pipe
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
// Start command
if err := cmd.Start(); err != nil {
return err
}
// Read and send output line by line
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
cmd.Process.Kill()
return err
}
}
// Wait for command to finish
if err := cmd.Wait(); err != nil {
return err
}
return scanner.Err()
}
func handleCoreLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Execute ha core logs command
output, err := executeHACommand([]string{"core", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Error encoding response: %v", err)
}
}
func handleSupervisorLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"supervisor", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleHostLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"host", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleAudioLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"audio", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleDNSLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"dns", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleMulticastLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"multicast", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleCoreLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to core logs follow")
if err := streamHACommandToWS(conn, []string{"core", "logs", "--follow"}); err != nil {
log.Printf("Error streaming core logs: %v", err)
}
log.Println("Client disconnected from core logs follow")
}
func handleSupervisorLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to supervisor logs follow")
if err := streamHACommandToWS(conn, []string{"supervisor", "logs", "--follow"}); err != nil {
log.Printf("Error streaming supervisor logs: %v", err)
}
log.Println("Client disconnected from supervisor logs follow")
}
func handleHostLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to host logs follow")
if err := streamHACommandToWS(conn, []string{"host", "logs", "--follow"}); err != nil {
log.Printf("Error streaming host logs: %v", err)
}
log.Println("Client disconnected from host logs follow")
}
func handleAudioLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to audio logs follow")
if err := streamHACommandToWS(conn, []string{"audio", "logs", "--follow"}); err != nil {
log.Printf("Error streaming audio logs: %v", err)
}
log.Println("Client disconnected from audio logs follow")
}
func handleDNSLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to dns logs follow")
if err := streamHACommandToWS(conn, []string{"dns", "logs", "--follow"}); err != nil {
log.Printf("Error streaming dns logs: %v", err)
}
log.Println("Client disconnected from dns logs follow")
}
func handleMulticastLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to multicast logs follow")
if err := streamHACommandToWS(conn, []string{"multicast", "logs", "--follow"}); err != nil {
log.Printf("Error streaming multicast logs: %v", err)
}
log.Println("Client disconnected from multicast logs follow")
}
func listEndpoints(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
endpoints := map[string]string{
"core": "/api/logs/core",
"supervisor": "/api/logs/supervisor",
"host": "/api/logs/host",
"audio": "/api/logs/audio",
"dns": "/api/logs/dns",
"multicast": "/api/logs/multicast",
"health": "/health",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(endpoints)
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "5642"
}
// Register handlers
http.HandleFunc("/api/logs", listEndpoints)
http.HandleFunc("/api/logs/core", handleCoreLogs)
http.HandleFunc("/api/logs/supervisor", handleSupervisorLogs)
http.HandleFunc("/api/logs/host", handleHostLogs)
http.HandleFunc("/api/logs/audio", handleAudioLogs)
http.HandleFunc("/api/logs/dns", handleDNSLogs)
http.HandleFunc("/api/logs/multicast", handleMulticastLogs)
// WebSocket follow endpoints
http.HandleFunc("/api/logs/core/follow", handleCoreLogsFollow)
http.HandleFunc("/api/logs/supervisor/follow", handleSupervisorLogsFollow)
http.HandleFunc("/api/logs/host/follow", handleHostLogsFollow)
http.HandleFunc("/api/logs/audio/follow", handleAudioLogsFollow)
http.HandleFunc("/api/logs/dns/follow", handleDNSLogsFollow)
http.HandleFunc("/api/logs/multicast/follow", handleMulticastLogsFollow)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
log.Printf("Starting HA Logs Proxy on port %s", port)
log.Printf("Available endpoints:")
log.Printf(" GET /api/logs - List all endpoints")
log.Printf(" GET /api/logs/core - Core logs")
log.Printf(" GET /api/logs/supervisor - Supervisor logs")
log.Printf(" GET /api/logs/host - Host logs")
log.Printf(" GET /api/logs/audio - Audio logs")
log.Printf(" GET /api/logs/dns - DNS logs")
log.Printf(" GET /api/logs/multicast - Multicast logs")
log.Printf(" WS /api/logs/*/follow - Stream logs (WebSocket)")
log.Printf(" GET /health - Health check")
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}

View File

@@ -1,19 +0,0 @@
---
image: ghcr.io/home-assistant/{arch}-hassio-cli
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base:3.22
armhf: ghcr.io/home-assistant/armhf-base:3.22
armv7: ghcr.io/home-assistant/armv7-base:3.22
amd64: ghcr.io/home-assistant/amd64-base:3.22
i386: ghcr.io/home-assistant/i386-base:3.22
args:
CLI_VERSION: 4.42.0
cosign:
enabled: true
repository_name: home-assistant/cli
repository_owner: home-assistant
labels:
org.opencontainers.image.title: Home Assistant CLI
org.opencontainers.image.description: Home Assistant CLI for container environments
org.opencontainers.image.source: https://github.com/home-assistant/cli
org.opencontainers.image.licenses: Apache-2.0

View File

@@ -1,16 +0,0 @@
---
services:
ha-cli:
build:
context: .
args:
BUILD_FROM: alpine:3.22
BUILD_ARCH: amd64
CLI_VERSION: 4.42.0
image: ha-cli:local
ports:
- "5642:5642"
environment:
- PORT=5642
stdin_open: true
tty: true

View File

@@ -1,34 +0,0 @@
import { darkSemanticColorStyles } from "../../src/resources/theme/color/semantic.globals";
import { darkColorStyles } from "../../src/resources/theme/color/color.globals";
const mql = matchMedia("(prefers-color-scheme: dark)");
function applyTheme(dark: boolean) {
const el = document.documentElement;
if (dark) {
el.setAttribute("dark", "");
} else {
el.removeAttribute("dark");
}
}
// Add dark theme styles wrapped in media query
// This runs after append-ha-style has loaded the base theme
const styleElement = document.createElement("style");
styleElement.id = "auto-theme-dark";
styleElement.textContent = `
@media (prefers-color-scheme: dark) {
${darkSemanticColorStyles.cssText}
${darkColorStyles.cssText}
}
`;
// Append to head to ensure it comes after base styles
document.head.appendChild(styleElement);
// Apply theme on initial load
applyTheme(mql.matches);
// Listen for theme changes
mql.addEventListener("change", (e) => {
applyTheme(e.matches);
});

View File

@@ -1,8 +0,0 @@
import "./logs-app";
// Load base styles first, then apply theme
import("../../src/resources/append-ha-style").then(() => {
import("./auto-theme");
});
document.body.appendChild(document.createElement("logs-app"));

View File

@@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<meta name="color-scheme" content="dark light" />
<title>Home Assistant Logs</title>
<% for (const entry of latestEntryJS) { %>
<script type="module" src="<%= entry %>"></script>
<% } %>
<style>
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
}
@media (prefers-color-scheme: dark) {
html {
background-color: var(--primary-background-color, #111111);
color: var(--primary-text-color, #e1e1e1);
}
}
body {
font-family: Roboto, Noto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-weight: 400;
margin: 0;
padding: 0;
}
</style>
</head>
<body></body>
</html>

View File

@@ -1,24 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement } from "lit/decorators";
import "./logs-viewer";
@customElement("logs-app")
class LogsApp extends LitElement {
render() {
return html`<logs-viewer></logs-viewer>`;
}
static styles = css`
:host {
display: block;
min-height: 100vh;
background-color: var(--primary-background-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"logs-app": LogsApp;
}
}

View File

@@ -1,538 +0,0 @@
import {
mdiArrowCollapseDown,
mdiChevronDown,
mdiCircle,
mdiDownload,
mdiRefresh,
mdiWrap,
mdiWrapDisabled,
} from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
// eslint-disable-next-line import/extensions
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import "../../src/components/ha-ansi-to-html";
import type { HaAnsiToHtml } from "../../src/components/ha-ansi-to-html";
import "../../src/components/ha-button";
import "../../src/components/ha-button-menu";
import "../../src/components/ha-card";
import "../../src/components/ha-icon-button";
import "../../src/components/ha-list-item";
import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
// Data types
interface LogProvider {
key: string;
name: string;
}
@customElement("logs-viewer")
export class LogsViewer extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _selectedLogProvider?: string;
@state() private _logProviders: LogProvider[] = [];
@state() private _loading = false;
@state() private _wrapLines = true;
@state() private _error?: string;
@state() private _newLogsIndicator?: boolean;
@query(".error-log") private _logElement?: HTMLElement;
@query("#scroll-top-marker") private _scrollTopMarkerElement?: HTMLElement;
@query("#scroll-bottom-marker")
private _scrollBottomMarkerElement?: HTMLElement;
@query("ha-ansi-to-html") private _ansiToHtmlElement?: HaAnsiToHtml;
@state() private _scrolledToBottomController =
new IntersectionController<boolean>(this, {
callback(this: IntersectionController<boolean>, entries) {
return entries[0].isIntersecting;
},
});
@state() private _scrolledToTopController =
new IntersectionController<boolean>(this, {});
private _ws: WebSocket | null = null;
private _apiUrl = `http://${window.location.hostname}:5642`;
private async _fetchLogs(): Promise<void> {
if (!this._selectedLogProvider) {
return;
}
this._loading = true;
this._error = undefined;
// Stop any existing websocket
this._stopFollowing();
// Clear existing logs
this._ansiToHtmlElement?.clear();
try {
// First, fetch the latest logs
const response = await fetch(
`${this._apiUrl}/api/logs/${this._selectedLogProvider}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const logText = data.output || "";
// Parse and display initial logs
if (logText.trim()) {
this._ansiToHtmlElement?.parseTextToColoredPre(logText);
// Add divider line
this._ansiToHtmlElement?.parseLineToColoredPre(
"--- Live logs start here ---"
);
}
this._loading = false;
// Scroll to bottom after loading
this._scrollToBottom();
// Start streaming
this._startFollowing();
} catch (err) {
this._error = `Error loading logs: ${err}`;
this._loading = false;
// eslint-disable-next-line
console.error("Error fetching logs:", err);
}
}
private async _fetchLogProviders(): Promise<void> {
try {
const response = await fetch(`${this._apiUrl}/api/logs`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const providers = await response.json();
// Define the order (matching backend registration order)
const order = ["core", "supervisor", "host", "audio", "dns", "multicast"];
// Convert object to array of providers, filter out health endpoint, and sort
this._logProviders = Object.entries(providers)
.filter(([key]) => key !== "health")
.map(([key]) => ({
key,
name: key.charAt(0).toUpperCase() + key.slice(1),
}))
.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key));
// Set default provider once loaded
if (this._logProviders.length > 0 && !this._selectedLogProvider) {
this._selectedLogProvider = this._logProviders[0].key;
await this._fetchLogs();
}
} catch (err) {
this._error = `Failed to load log providers: ${err}`;
// eslint-disable-next-line
console.error("Error fetching log providers:", err);
}
}
connectedCallback() {
super.connectedCallback();
this._fetchLogProviders();
}
disconnectedCallback() {
super.disconnectedCallback();
this._stopFollowing();
}
protected firstUpdated() {
this._scrolledToBottomController.observe(this._scrollBottomMarkerElement!);
this._scrolledToTopController.observe(this._scrollTopMarkerElement!);
}
protected updated() {
if (this._newLogsIndicator && this._scrolledToBottomController.value) {
this._newLogsIndicator = false;
}
}
private _selectProvider(ev: Event) {
const target = ev.currentTarget as any;
this._selectedLogProvider = target.provider;
this._fetchLogs();
}
private _refresh() {
this._fetchLogs();
}
private _toggleLineWrap() {
this._wrapLines = !this._wrapLines;
}
private _scrollToBottom(): void {
if (this._logElement) {
this._newLogsIndicator = false;
this._logElement.scrollTo(0, this._logElement.scrollHeight);
}
}
private _startFollowing() {
if (!this._selectedLogProvider) {
return;
}
this._stopFollowing();
this._error = undefined;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.hostname}:5642/api/logs/${this._selectedLogProvider}/follow`;
try {
this._ws = new WebSocket(wsUrl);
this._ws.onopen = () => {
// eslint-disable-next-line
console.log("WebSocket connected");
};
this._ws.onmessage = (event) => {
const scrolledToBottom = this._scrolledToBottomController.value;
// Add the new line to the display
this._ansiToHtmlElement?.parseLineToColoredPre(event.data);
// Auto-scroll if user is at bottom
if (scrolledToBottom && this._logElement) {
this._scrollToBottom();
} else {
this._newLogsIndicator = true;
}
};
this._ws.onerror = (error) => {
// eslint-disable-next-line
console.error("WebSocket error:", error);
this._error = "WebSocket connection error";
};
this._ws.onclose = () => {
// eslint-disable-next-line
console.log("WebSocket disconnected");
};
} catch (err) {
this._error = `Failed to start following logs: ${err}`;
// eslint-disable-next-line
console.error("Error starting WebSocket:", err);
}
}
private _stopFollowing() {
if (this._ws) {
this._ws.close();
this._ws = null;
}
}
private _downloadLogs() {
if (!this._selectedLogProvider || !this._ansiToHtmlElement) {
return;
}
// Get the text content from the logs
const logText =
this._ansiToHtmlElement.shadowRoot?.querySelector("pre")?.textContent ||
"";
if (!logText.trim()) {
return;
}
// Create blob from log text
const blob = new Blob([logText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
// Create download link and trigger it
const a = document.createElement("a");
a.href = url;
a.download = `${this._selectedLogProvider}-logs-${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
// Cleanup
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
render() {
const currentProvider = this._logProviders.find(
(p) => p.key === this._selectedLogProvider
);
return html`
<div class="container">
<div class="toolbar">
<ha-button-menu>
<ha-button slot="trigger" appearance="filled">
<ha-svg-icon slot="end" .path=${mdiChevronDown}></ha-svg-icon>
${currentProvider?.name || "Select Provider"}
</ha-button>
${this._logProviders.map(
(provider) => html`
<ha-list-item
?selected=${provider.key === this._selectedLogProvider}
.provider=${provider.key}
@click=${this._selectProvider}
>
${provider.name}
</ha-list-item>
`
)}
</ha-button-menu>
</div>
<div class="content">
<div class="error-log-intro">
<ha-card outlined>
<div class="header">
<h1 class="card-header">${currentProvider?.name || "Logs"}</h1>
<div class="action-buttons">
<ha-icon-button
.path=${mdiDownload}
@click=${this._downloadLogs}
.label=${"Download logs"}
.disabled=${!this._ansiToHtmlElement}
></ha-icon-button>
<ha-icon-button
.path=${this._wrapLines ? mdiWrapDisabled : mdiWrap}
@click=${this._toggleLineWrap}
.label=${this._wrapLines ? "Full width" : "Wrap lines"}
></ha-icon-button>
<ha-icon-button
.path=${mdiRefresh}
@click=${this._refresh}
.label=${"Refresh"}
.disabled=${!this._selectedLogProvider}
></ha-icon-button>
</div>
</div>
<div class="card-content error-log">
<div id="scroll-top-marker"></div>
${this._loading
? html`<div>Loading logs...</div>`
: this._error
? html`<div class="error">${this._error}</div>`
: nothing}
<ha-ansi-to-html
?wrap-disabled=${!this._wrapLines}
></ha-ansi-to-html>
<div id="scroll-bottom-marker"></div>
</div>
<ha-button
class="new-logs-indicator ${classMap({
visible:
(this._newLogsIndicator &&
!this._scrolledToBottomController.value) ||
false,
})}"
size="small"
appearance="filled"
@click=${this._scrollToBottom}
>
<ha-svg-icon
.path=${mdiArrowCollapseDown}
slot="start"
></ha-svg-icon>
Scroll down
<ha-svg-icon
.path=${mdiArrowCollapseDown}
slot="end"
></ha-svg-icon>
</ha-button>
${this._ws && !this._error
? html`<div class="live-indicator">
<ha-svg-icon .path=${mdiCircle}></ha-svg-icon>
Live
</div>`
: nothing}
</ha-card>
</div>
</div>
</div>
`;
}
static styles: CSSResultGroup = css`
:host {
display: block;
direction: var(--direction);
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.toolbar {
padding: var(--ha-space-2) var(--ha-space-4);
display: flex;
justify-content: flex-end;
gap: var(--ha-space-2);
}
.content {
direction: ltr;
}
.error-log-intro {
text-align: center;
margin: 0 var(--ha-space-4);
}
ha-card {
padding-top: var(--ha-space-2);
position: relative;
}
.header {
display: flex;
justify-content: space-between;
padding: 0 var(--ha-space-4);
}
.action-buttons {
display: flex;
align-items: center;
height: 100%;
}
.card-header {
color: var(--ha-card-header-color, var(--primary-text-color));
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: var(--ha-line-height-expanded);
display: block;
margin-block-start: 0px;
font-weight: var(--ha-font-weight-normal);
white-space: nowrap;
max-width: calc(100% - 150px);
overflow: hidden;
text-overflow: ellipsis;
}
.error-log {
position: relative;
font-family: var(--ha-font-family-code);
clear: both;
text-align: start;
padding-top: var(--ha-space-4);
padding-bottom: var(--ha-space-4);
overflow-y: scroll;
min-height: var(--error-log-card-height, calc(100vh - 244px));
max-height: var(--error-log-card-height, calc(100vh - 244px));
border-top: 1px solid var(--divider-color);
direction: ltr;
}
.error-log > div {
padding: 0 var(--ha-space-4);
overflow: auto;
}
.error {
color: var(--error-color);
padding: var(--ha-space-4);
}
.new-logs-indicator {
overflow: hidden;
position: absolute;
bottom: var(--ha-space-1);
left: var(--ha-space-1);
height: 0;
transition: height 0.4s ease-out;
}
.new-logs-indicator.visible {
height: 32px;
}
@keyframes breathe {
from {
opacity: 0.8;
}
to {
opacity: 0;
}
}
.live-indicator {
position: absolute;
bottom: 0;
inset-inline-end: var(--ha-space-4);
border-top-right-radius: var(--ha-space-2);
border-top-left-radius: var(--ha-space-2);
background-color: var(--primary-color);
color: var(--text-primary-color);
padding: var(--ha-space-1) var(--ha-space-2);
opacity: 0.8;
}
.live-indicator ha-svg-icon {
animation: breathe 1s cubic-bezier(0.5, 0, 1, 1) infinite alternate;
height: 14px;
width: 14px;
}
@media all and (max-width: 870px) {
.error-log {
min-height: var(--error-log-card-height, calc(100vh - 190px));
max-height: var(--error-log-card-height, calc(100vh - 190px));
}
ha-button-menu {
max-width: 50%;
}
ha-button {
max-width: 100%;
}
ha-button::part(label) {
overflow: hidden;
white-space: nowrap;
}
}
ha-list-item[selected] {
color: var(--primary-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"logs-viewer": LogsViewer;
}
}

View File

@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.7",
"@home-assistant/webawesome": "3.0.0",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6",
@@ -89,8 +89,8 @@
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.9.4",
"@vaadin/vaadin-themable-mixin": "24.9.4",
"@vaadin/combo-box": "24.9.5",
"@vaadin/vaadin-themable-mixin": "24.9.5",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -115,14 +115,14 @@
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.18",
"js-yaml": "4.1.0",
"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",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.1",
"lit-html": "3.3.1",
"luxon": "3.7.2",
"marked": "16.4.1",
"marked": "17.0.0",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"object-hash": "3.0.0",
@@ -157,8 +157,8 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.3.7",
"@rspack/core": "1.6.0",
"@rsdoctor/rspack-plugin": "1.3.8",
"@rspack/core": "1.6.1",
"@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22",
@@ -178,7 +178,7 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.6",
"@vitest/coverage-v8": "4.0.8",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
@@ -194,7 +194,7 @@
"eslint-plugin-wc": "3.0.2",
"fancy-log": "2.0.0",
"fs-extra": "11.3.2",
"glob": "11.0.3",
"glob": "12.0.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
@@ -219,7 +219,7 @@
"typescript": "5.9.3",
"typescript-eslint": "8.46.3",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.6",
"vitest": "4.0.8",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -233,9 +233,10 @@
"@fullcalendar/daygrid": "6.1.19",
"globals": "16.5.0",
"tslib": "2.8.1",
"@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"
"@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.10.3",
"packageManager": "yarn@4.11.0",
"volta": {
"node": "22.21.1"
}

View File

@@ -1,96 +0,0 @@
#!/bin/sh
# Run the logs frontend development server
# Stop on errors
set -e
cd "$(dirname "$0")/.."
# Parse command line arguments
SUPERVISOR_ENDPOINT=""
SUPERVISOR_API_TOKEN=""
while getopts "c:t:h" opt; do
case $opt in
c)
SUPERVISOR_ENDPOINT="$OPTARG"
;;
t)
SUPERVISOR_API_TOKEN="$OPTARG"
;;
h)
echo "Usage: $0 -c SUPERVISOR_ENDPOINT -t SUPERVISOR_API_TOKEN"
echo ""
echo "Options:"
echo " -c SUPERVISOR_ENDPOINT (e.g., http://192.168.1.2) [required]"
echo " -t SUPERVISOR_API_TOKEN (from remote_api add-on) [required]"
echo " -h Show this help message"
echo ""
echo "Example:"
echo " $0 -c http://192.168.1.2 -t your_token_here"
exit 0
;;
\?)
echo "Invalid option: -$OPTARG" >&2
echo "Use -h for help"
exit 1
;;
esac
done
# Validate that both -c and -t are provided
if [ -z "$SUPERVISOR_ENDPOINT" ] || [ -z "$SUPERVISOR_API_TOKEN" ]; then
echo "Error: Both -c and -t are required" >&2
echo "Use -h for help"
exit 1
fi
# Cleanup function
cleanup() {
echo ""
echo "Shutting down..."
echo "Stopping HA CLI container..."
docker stop ha-cli-dev 2>/dev/null || true
exit 0
}
# Set up trap to cleanup on exit
trap cleanup INT TERM EXIT
# Run HA CLI container
echo "Starting HA CLI container..."
# Build the container if needed
if ! docker images | grep -q "ha-cli:local"; then
echo "Building HA CLI container..."
(cd logs && docker compose build)
fi
# Clean up any existing container
docker stop ha-cli-dev 2>/dev/null || true
# Run the container in background (not detached, so it shares stdout)
docker run \
--name ha-cli-dev \
--rm \
-p 5642:5642 \
-e SUPERVISOR_ENDPOINT="$SUPERVISOR_ENDPOINT" \
-e SUPERVISOR_API_TOKEN="$SUPERVISOR_API_TOKEN" \
-e PORT=5642 \
ha-cli:local &
# Store the docker process ID
DOCKER_PID=$!
# Wait a moment for container to start
sleep 2
echo ""
echo "HA Logs Backend API: http://localhost:5642"
echo " GET /api/logs - List endpoints"
echo " GET /api/logs/core - Core logs"
echo " GET /api/logs/supervisor - Supervisor logs"
echo " GET /health - Health check"
echo ""
# Run gulp (this will block until Ctrl+C)
./node_modules/.bin/gulp develop-logs

View File

@@ -1,5 +1,4 @@
#!/bin/sh
# Pushes a new version to PyPi.
# Stop on errors
set -e
@@ -12,5 +11,4 @@ yarn install
script/build_frontend
rm -rf dist home_assistant_frontend.egg-info
python3 -m build
python3 -m twine upload dist/*.whl --skip-existing
python3 -m build -q

View File

@@ -0,0 +1,53 @@
import type { AreaRegistryEntry } from "../../data/area_registry";
import type { FloorRegistryEntry } from "../../data/floor_registry";
export interface AreasFloorHierarchy {
floors: {
id: string;
areas: string[];
}[];
areas: string[];
}
export const getAreasFloorHierarchy = (
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[]
): AreasFloorHierarchy => {
const floorAreas = new Map<string, string[]>();
const unassignedAreas: string[] = [];
for (const area of areas) {
if (area.floor_id) {
if (!floorAreas.has(area.floor_id)) {
floorAreas.set(area.floor_id, []);
}
floorAreas.get(area.floor_id)!.push(area.area_id);
} else {
unassignedAreas.push(area.area_id);
}
}
const hierarchy: AreasFloorHierarchy = {
floors: floors.map((floor) => ({
id: floor.floor_id,
areas: floorAreas.get(floor.floor_id) || [],
})),
areas: unassignedAreas,
};
return hierarchy;
};
export const getAreasOrder = (hierarchy: AreasFloorHierarchy): string[] => {
const order: string[] = [];
for (const floor of hierarchy.floors) {
order.push(...floor.areas);
}
order.push(...hierarchy.areas);
return order;
};
export const getFloorOrder = (hierarchy: AreasFloorHierarchy): string[] =>
hierarchy.floors.map((floor) => floor.id);

View File

@@ -0,0 +1,36 @@
import type {
Condition,
TimeCondition,
} from "../../panels/lovelace/common/validate-condition";
/**
* Extract media queries from conditions recursively
*/
export function extractMediaQueries(conditions: Condition[]): string[] {
return conditions.reduce<string[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractMediaQueries(c.conditions));
}
if (c.condition === "screen" && c.media_query) {
array.push(c.media_query);
}
return array;
}, []);
}
/**
* Extract time conditions from conditions recursively
*/
export function extractTimeConditions(
conditions: Condition[]
): TimeCondition[] {
return conditions.reduce<TimeCondition[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractTimeConditions(c.conditions));
}
if (c.condition === "time") {
array.push(c);
}
return array;
}, []);
}

View File

@@ -0,0 +1,89 @@
import { listenMediaQuery } from "../dom/media_query";
import type { HomeAssistant } from "../../types";
import type { Condition } from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import { extractMediaQueries, extractTimeConditions } from "./extract";
import { calculateNextTimeUpdate } from "./time-calculator";
/** Maximum delay for setTimeout (2^31 - 1 milliseconds, ~24.8 days)
* Values exceeding this will overflow and execute immediately
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value
*/
const MAX_TIMEOUT_DELAY = 2147483647;
/**
* Helper to setup media query listeners for conditional visibility
*/
export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void
): void {
const mediaQueries = extractMediaQueries(conditions);
if (mediaQueries.length === 0) return;
// Optimization for single media query
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
mediaQueries.forEach((mediaQuery) => {
const unsub = listenMediaQuery(mediaQuery, (matches) => {
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
const conditionsMet = checkConditionsMet(conditions, hass);
onUpdate(conditionsMet);
}
});
addListener(unsub);
});
}
/**
* Helper to setup time-based listeners for conditional visibility
*/
export function setupTimeListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void
): void {
const timeConditions = extractTimeConditions(conditions);
if (timeConditions.length === 0) return;
timeConditions.forEach((timeCondition) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const scheduleUpdate = () => {
const delay = calculateNextTimeUpdate(hass, timeCondition);
if (delay === undefined) return;
// Cap delay to prevent setTimeout overflow
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
const conditionsMet = checkConditionsMet(conditions, hass);
onUpdate(conditionsMet);
}
scheduleUpdate();
}, cappedDelay);
};
// Register cleanup function once, outside of scheduleUpdate
addListener(() => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
});
scheduleUpdate();
});
}

View File

@@ -0,0 +1,73 @@
import { TZDate } from "@date-fns/tz";
import {
startOfDay,
addDays,
addMinutes,
differenceInMilliseconds,
} from "date-fns";
import type { HomeAssistant } from "../../types";
import { TimeZone } from "../../data/translation";
import { parseTimeString } from "../datetime/check_time";
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
/**
* Calculate milliseconds until next time boundary for a time condition
* @param hass Home Assistant object
* @param timeCondition Time condition to calculate next update for
* @returns Milliseconds until next boundary, or undefined if no boundaries
*/
export function calculateNextTimeUpdate(
hass: HomeAssistant,
{ after, before, weekdays }: Omit<TimeCondition, "condition">
): number | undefined {
const timezone =
hass.locale.time_zone === TimeZone.server
? hass.config.time_zone
: Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new TZDate(new Date(), timezone);
const updates: Date[] = [];
// Calculate next occurrence of after time
if (after) {
let afterDate = parseTimeString(after, timezone);
if (afterDate <= now) {
// If time has passed today, schedule for tomorrow
afterDate = addDays(afterDate, 1);
}
updates.push(afterDate);
}
// Calculate next occurrence of before time
if (before) {
let beforeDate = parseTimeString(before, timezone);
if (beforeDate <= now) {
// If time has passed today, schedule for tomorrow
beforeDate = addDays(beforeDate, 1);
}
updates.push(beforeDate);
}
// If weekdays are specified, check for midnight (weekday transition)
if (weekdays && weekdays.length > 0 && weekdays.length < 7) {
// Calculate next midnight using startOfDay + addDays
const tomorrow = addDays(now, 1);
const midnight = startOfDay(tomorrow);
updates.push(midnight);
}
if (updates.length === 0) {
return undefined;
}
// Find the soonest update time
const nextUpdate = updates.reduce((soonest, current) =>
current < soonest ? current : soonest
);
// Add 1 minute buffer to ensure we're past the boundary
const updateWithBuffer = addMinutes(nextUpdate, 1);
// Calculate difference in milliseconds
return differenceInMilliseconds(updateWithBuffer, now);
}

View File

@@ -0,0 +1,131 @@
import { TZDate } from "@date-fns/tz";
import { isBefore, isAfter, isWithinInterval } from "date-fns";
import type { HomeAssistant } from "../../types";
import { TimeZone } from "../../data/translation";
import { WEEKDAY_MAP } from "./weekday";
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
/**
* Validate a time string format and value ranges without creating Date objects
* @param timeString Time string to validate (HH:MM or HH:MM:SS)
* @returns true if valid, false otherwise
*/
export function isValidTimeString(timeString: string): boolean {
// Reject empty strings
if (!timeString || timeString.trim() === "") {
return false;
}
const parts = timeString.split(":");
if (parts.length < 2 || parts.length > 3) {
return false;
}
// Ensure each part contains only digits (and optional leading zeros)
// This prevents "8:00 AM" from passing validation
if (!parts.every((part) => /^\d+$/.test(part))) {
return false;
}
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
return false;
}
return (
hours >= 0 &&
hours <= 23 &&
minutes >= 0 &&
minutes <= 59 &&
seconds >= 0 &&
seconds <= 59
);
}
/**
* Parse a time string (HH:MM or HH:MM:SS) and set it on today's date in the given timezone
*
* Note: This function assumes the time string has already been validated by
* isValidTimeString() at configuration time. It does not re-validate at runtime
* for consistency with other condition types (screen, user, location, etc.)
*
* @param timeString The time string to parse (must be pre-validated)
* @param timezone The timezone to use
* @returns The Date object
*/
export const parseTimeString = (timeString: string, timezone: string): Date => {
const parts = timeString.split(":");
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
const now = new TZDate(new Date(), timezone);
const dateWithTime = new TZDate(
now.getFullYear(),
now.getMonth(),
now.getDate(),
hours,
minutes,
seconds,
0,
timezone
);
return new Date(dateWithTime.getTime());
};
/**
* Check if the current time matches the time condition (after/before/weekday)
* @param hass Home Assistant object
* @param timeCondition Time condition to check
* @returns true if current time matches the condition
*/
export const checkTimeInRange = (
hass: HomeAssistant,
{ after, before, weekdays }: Omit<TimeCondition, "condition">
): boolean => {
const timezone =
hass.locale.time_zone === TimeZone.server
? hass.config.time_zone
: Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new TZDate(new Date(), timezone);
// Check weekday condition
if (weekdays && weekdays.length > 0) {
const currentWeekday = WEEKDAY_MAP[now.getDay()];
if (!weekdays.includes(currentWeekday)) {
return false;
}
}
// Check time conditions
if (!after && !before) {
return true;
}
const afterDate = after ? parseTimeString(after, timezone) : undefined;
const beforeDate = before ? parseTimeString(before, timezone) : undefined;
if (afterDate && beforeDate) {
if (isBefore(beforeDate, afterDate)) {
// Crosses midnight (e.g., 22:00 to 06:00)
return !isBefore(now, afterDate) || !isAfter(now, beforeDate);
}
return isWithinInterval(now, { start: afterDate, end: beforeDate });
}
if (afterDate) {
return !isBefore(now, afterDate);
}
if (beforeDate) {
return !isAfter(now, beforeDate);
}
return true;
};

View File

@@ -1,18 +1,7 @@
import { getWeekStartByLocale } from "weekstart";
import type { FrontendLocaleData } from "../../data/translation";
import { FirstWeekday } from "../../data/translation";
export const weekdays = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const;
type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
import { WEEKDAYS_LONG, type WeekdayIndex } from "./weekday";
export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
if (locale.first_weekday === FirstWeekday.language) {
@@ -23,12 +12,12 @@ export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
}
return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex;
}
return weekdays.includes(locale.first_weekday)
? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex)
return WEEKDAYS_LONG.includes(locale.first_weekday)
? (WEEKDAYS_LONG.indexOf(locale.first_weekday) as WeekdayIndex)
: 1;
};
export const firstWeekday = (locale: FrontendLocaleData) => {
const index = firstWeekdayIndex(locale);
return weekdays[index];
return WEEKDAYS_LONG[index];
};

View File

@@ -0,0 +1,59 @@
export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export type WeekdayShort =
| "sun"
| "mon"
| "tue"
| "wed"
| "thu"
| "fri"
| "sat";
export type WeekdayLong =
| "sunday"
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday";
export const WEEKDAYS_SHORT = [
"sun",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
] as const satisfies readonly WeekdayShort[];
export const WEEKDAYS_LONG = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const satisfies readonly WeekdayLong[];
export const WEEKDAY_MAP = {
0: "sun",
1: "mon",
2: "tue",
3: "wed",
4: "thu",
5: "fri",
6: "sat",
} as const satisfies Record<WeekdayIndex, WeekdayShort>;
export const WEEKDAY_SHORT_TO_LONG = {
sun: "sunday",
mon: "monday",
tue: "tuesday",
wed: "wednesday",
thu: "thursday",
fri: "friday",
sat: "saturday",
} as const satisfies Record<WeekdayShort, WeekdayLong>;

View File

@@ -1,5 +1,6 @@
import type { ThemeVars } from "../../data/ws-themes";
import { darkColorVariables } from "../../resources/theme/color";
import { darkSemanticVariables } from "../../resources/theme/semantic.globals";
import { derivedStyles } from "../../resources/theme/theme";
import type { HomeAssistant } from "../../types";
import {
@@ -52,7 +53,7 @@ export const applyThemesOnElement = (
if (themeToApply && darkMode) {
cacheKey = `${cacheKey}__dark`;
themeRules = { ...darkColorVariables };
themeRules = { ...darkSemanticVariables, ...darkColorVariables };
}
if (themeToApply === "default") {

View File

@@ -0,0 +1,67 @@
import { tinykeys } from "tinykeys";
import { canOverrideAlphanumericInput } from "../dom/can-override-input";
/**
* A function to handle a keyboard shortcut.
*/
export type ShortcutHandler = (event: KeyboardEvent) => void;
/**
* Configuration for a keyboard shortcut.
*/
export interface ShortcutConfig {
handler: ShortcutHandler;
/**
* If true, allows shortcuts even when text is selected.
* Default is false to avoid interrupting copy/paste.
*/
allowWhenTextSelected?: boolean;
}
/**
* Register keyboard shortcuts using tinykeys.
* Automatically blocks shortcuts in input fields and during text selection.
*/
function registerShortcuts(
shortcuts: Record<string, ShortcutConfig>
): () => void {
const wrappedShortcuts: Record<string, ShortcutHandler> = {};
Object.entries(shortcuts).forEach(([key, config]) => {
wrappedShortcuts[key] = (event: KeyboardEvent) => {
if (!canOverrideAlphanumericInput(event.composedPath())) {
return;
}
if (!config.allowWhenTextSelected && window.getSelection()?.toString()) {
return;
}
config.handler(event);
};
});
return tinykeys(window, wrappedShortcuts);
}
/**
* Manages keyboard shortcuts registration and cleanup.
*/
export class ShortcutManager {
private _disposer?: () => void;
/**
* Register keyboard shortcuts.
* Uses tinykeys syntax: https://github.com/jamiebuilds/tinykeys#usage
*/
public add(shortcuts: Record<string, ShortcutConfig>) {
this._disposer?.();
this._disposer = registerShortcuts(shortcuts);
}
/**
* Remove all registered shortcuts.
*/
public remove() {
this._disposer?.();
this._disposer = undefined;
}
}

View File

@@ -0,0 +1,36 @@
/**
* Parses a CSS duration string (e.g., "300ms", "3s") and returns the duration in milliseconds.
*
* @param duration - A CSS duration string (e.g., "300ms", "3s", "0.5s")
* @returns The duration in milliseconds, or 0 if the input is invalid
*
* @example
* parseAnimationDuration("300ms") // Returns 300
* parseAnimationDuration("3s") // Returns 3000
* parseAnimationDuration("0.5s") // Returns 500
* parseAnimationDuration("invalid") // Returns 0
*/
export const parseAnimationDuration = (duration: string): number => {
const trimmed = duration.trim();
let value: number;
let multiplier: number;
if (trimmed.endsWith("ms")) {
value = parseFloat(trimmed.slice(0, -2));
multiplier = 1;
} else if (trimmed.endsWith("s")) {
value = parseFloat(trimmed.slice(0, -1));
multiplier = 1000;
} else {
// No recognized unit, try parsing as number (assume ms)
value = parseFloat(trimmed);
multiplier = 1;
}
if (!isFinite(value) || value < 0) {
return 0;
}
return value * multiplier;
};

View File

@@ -119,8 +119,8 @@ type Thresholds = Record<
>;
export const DEFAULT_THRESHOLDS: Thresholds = {
second: 45, // seconds to minute
minute: 45, // minutes to hour
second: 59, // seconds to minute
minute: 59, // minutes to hour
hour: 22, // hour to day
day: 5, // day to week
week: 4, // week to months

View File

@@ -0,0 +1,30 @@
/**
* Executes a callback within a View Transition if supported, otherwise runs it directly.
*
* @param callback - Function to execute. Can be synchronous or return a Promise. The callback will be passed a boolean indicating whether the view transition is available.
* @returns Promise that resolves when the transition completes (or immediately if not supported)
*
* @example
* ```typescript
* // Synchronous callback
* withViewTransition(() => {
* this.large = !this.large;
* });
*
* // Async callback
* await withViewTransition(async () => {
* await this.updateData();
* });
* ```
*/
export const withViewTransition = (
callback: (viewTransitionAvailable: boolean) => void | Promise<void>
): Promise<void> => {
if (document.startViewTransition) {
return document.startViewTransition(() => callback(true)).finished;
}
// Fallback: Execute callback directly without transition
const result = callback(false);
return result instanceof Promise ? result : Promise.resolve();
};

View File

@@ -6,7 +6,8 @@ export function downSampleLineData<
data: T[] | undefined,
maxDetails: number,
minX?: number,
maxX?: number
maxX?: number,
useMean = false
): T[] {
if (!data) {
return [];
@@ -17,15 +18,13 @@ export function downSampleLineData<
const min = minX ?? getPointData(data[0]!)[0];
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.ceil((max - min) / Math.floor(maxDetails));
const frames = new Map<
number,
{
min: { point: (typeof data)[number]; x: number; y: number };
max: { point: (typeof data)[number]; x: number; y: number };
}
>();
// Group points into frames
const frames = new Map<
number,
{ point: (typeof data)[number]; x: number; y: number }[]
>();
for (const point of data) {
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
@@ -36,28 +35,53 @@ export function downSampleLineData<
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, { min: { point, x, y }, max: { point, x, y } });
frames.set(frameIndex, [{ point, x, y }]);
} else {
if (frame.min.y > y) {
frame.min = { point, x, y };
}
if (frame.max.y < y) {
frame.max = { point, x, y };
}
frame.push({ point, x, y });
}
}
// Convert frames back to points
const result: T[] = [];
for (const [_i, frame] of frames) {
// Use min/max points to preserve visual accuracy
// The order of the data must be preserved so max may be before min
if (frame.min.x > frame.max.x) {
result.push(frame.max.point);
if (useMean) {
// Use mean values for each frame
for (const [_i, framePoints] of frames) {
const sumY = framePoints.reduce((acc, p) => acc + p.y, 0);
const meanY = sumY / framePoints.length;
const sumX = framePoints.reduce((acc, p) => acc + p.x, 0);
const meanX = sumX / framePoints.length;
const firstPoint = framePoints[0].point;
const pointData = getPointData(firstPoint);
const meanPoint = (
Array.isArray(pointData) ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
}
result.push(frame.min.point);
if (frame.min.x < frame.max.x) {
result.push(frame.max.point);
} else {
// Use min/max values for each frame
for (const [_i, framePoints] of frames) {
let minPoint = framePoints[0];
let maxPoint = framePoints[0];
for (const p of framePoints) {
if (p.y < minPoint.y) {
minPoint = p;
}
if (p.y > maxPoint.y) {
maxPoint = p;
}
}
// The order of the data must be preserved so max may be before min
if (minPoint.x > maxPoint.x) {
result.push(maxPoint.point);
}
result.push(minPoint.point);
if (minPoint.x < maxPoint.x) {
result.push(maxPoint.point);
}
}
}

View File

@@ -427,6 +427,7 @@ export class HaChartBase extends LitElement {
...axis.axisPointer?.handle,
show: true,
},
label: { show: false },
},
}
: axis
@@ -627,6 +628,10 @@ export class HaChartBase extends LitElement {
}
private _createTheme(style: CSSStyleDeclaration) {
const textBorderColor =
style.getPropertyValue("--ha-card-background") ||
style.getPropertyValue("--card-background-color");
const textBorderWidth = 2;
return {
color: getAllGraphColors(style),
backgroundColor: "transparent",
@@ -650,22 +655,22 @@ export class HaChartBase extends LitElement {
graph: {
label: {
color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"),
textBorderWidth: 2,
textBorderColor,
textBorderWidth,
},
},
pie: {
label: {
color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"),
textBorderWidth: 2,
textBorderColor,
textBorderWidth,
},
},
sankey: {
label: {
color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"),
textBorderWidth: 2,
textBorderColor,
textBorderWidth,
},
},
categoryAxis: {

View File

@@ -2,7 +2,10 @@ import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
@@ -16,6 +19,7 @@ import { deepEqual } from "../../common/util/deep-equal";
export interface NetworkNode {
id: string;
name?: string;
context?: string;
category?: number;
value?: number;
symbolSize?: number;
@@ -188,6 +192,25 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
label: {
show: showLabels,
position: "right",
formatter: (params: CallbackDataParams) => {
const node = params.data as NetworkNode;
if (node.context) {
return `{primary|${node.name ?? ""}}\n{secondary|${node.context}}`;
}
return node.name ?? "";
},
rich: {
primary: {
fontSize: 12,
},
secondary: {
fontSize: 12,
color: getComputedStyle(document.body).getPropertyValue(
"--secondary-text-color"
),
lineHeight: 16,
},
},
},
emphasis: {
focus: isMobile ? "none" : "adjacency",
@@ -225,6 +248,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
({
id: node.id,
name: node.name,
context: node.context,
category: node.category,
value: node.value,
symbolSize: node.symbolSize || 30,

View File

@@ -30,6 +30,7 @@ export class HaFilterChip extends FilterChip {
var(--rgb-primary-text-color),
0.15
);
--_label-text-font: var(--ha-font-family-body);
border-radius: var(--ha-border-radius-md);
}
`,

View File

@@ -62,6 +62,7 @@ class HaDataTableLabels extends LitElement {
@click=${clickAction ? this._labelClicked : undefined}
@keydown=${clickAction ? this._labelClicked : undefined}
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label?.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`

View File

@@ -298,6 +298,18 @@ export class HaDataTable extends LitElement {
}
if (properties.has("data")) {
// Clean up checked rows that no longer exist in the data
if (this._checkedRows.length) {
const validIds = new Set(this.data.map((row) => String(row[this.id])));
const validCheckedRows = this._checkedRows.filter((id) =>
validIds.has(id)
);
if (validCheckedRows.length !== this._checkedRows.length) {
this._checkedRows = validCheckedRows;
this._checkedRowsChanged();
}
}
this._checkableRowsCount = this.data.filter(
(row) => row.selectable !== false
).length;

View File

@@ -197,9 +197,6 @@ export class HaDevicePicker extends LitElement {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.device-picker.no_match"
);
const valueRenderer = this._valueRenderer(this._configEntryLookup);
@@ -209,7 +206,10 @@ export class HaDevicePicker extends LitElement {
.autofocus=${this.autofocus}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.device-picker.no_devices"
)}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
@@ -233,6 +233,11 @@ export class HaDevicePicker extends LitElement {
this.value = value;
fireEvent(this, "value-changed", { value });
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.device-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
declare global {

View File

@@ -269,9 +269,6 @@ export class HaEntityPicker extends LitElement {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.entity.entity-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.entity.entity-picker.no_match"
);
return html`
<ha-generic-picker
@@ -282,7 +279,7 @@ export class HaEntityPicker extends LitElement {
.label=${this.label}
.helper=${this.helper}
.searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel}
.notFoundLabel=${this._notFoundLabel}
.placeholder=${placeholder}
.value=${this.addButton ? undefined : this.value}
.rowRenderer=${this._rowRenderer}
@@ -356,6 +353,11 @@ export class HaEntityPicker extends LitElement {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.entity.entity-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
declare global {

View File

@@ -21,7 +21,6 @@ import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-icon-button";
import "../ha-input-helper-text";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
@@ -271,7 +270,6 @@ export class HaStatisticPicker extends LitElement {
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({
@@ -279,7 +277,6 @@ export class HaStatisticPicker extends LitElement {
statistic_id: id,
primary,
secondary,
a11y_label: a11yLabel,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
@@ -458,9 +455,6 @@ export class HaStatisticPicker extends LitElement {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.statistic-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.statistic-picker.no_match"
);
return html`
<ha-generic-picker
@@ -468,7 +462,10 @@ export class HaStatisticPicker extends LitElement {
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.label=${this.label}
.notFoundLabel=${notFoundLabel}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.statistic-picker.no_statistics"
)}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
@@ -477,6 +474,7 @@ export class HaStatisticPicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
.helper=${this.helper}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -521,6 +519,11 @@ export class HaStatisticPicker extends LitElement {
await this.updateComplete;
await this._picker?.open();
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.statistic-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
declare global {

View File

@@ -369,9 +369,10 @@ export class HaAreaPicker extends LitElement {
.autofocus=${this.autofocus}
.label=${this.label}
.helper=${this.helper}
.notFoundLabel=${this.hass.localize(
"ui.components.area-picker.no_match"
)}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize("ui.components.area-picker.no_areas")}
.disabled=${this.disabled}
.required=${this.required}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
@@ -425,6 +426,11 @@ export class HaAreaPicker extends LitElement {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.area-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
declare global {

View File

@@ -94,6 +94,12 @@ export class HaDateInput extends LitElement {
}
private _keyDown(ev: KeyboardEvent) {
if (["Space", "Enter"].includes(ev.code)) {
ev.preventDefault();
ev.stopPropagation();
this._openDialog();
return;
}
if (!this.canClear) {
return;
}

View File

@@ -0,0 +1,41 @@
import DropdownItem from "@home-assistant/webawesome/dist/components/dropdown-item/dropdown-item";
import { css, type CSSResultGroup } from "lit";
import { customElement } from "lit/decorators";
/**
* Home Assistant dropdown item component
*
* @element ha-dropdown-item
* @extends {DropdownItem}
*
* @summary
* A stylable dropdown item component supporting Home Assistant theming, variants, and appearances based on webawesome dropdown item.
*
*/
@customElement("ha-dropdown-item")
export class HaDropdownItem extends DropdownItem {
static get styles(): CSSResultGroup {
return [
DropdownItem.styles,
css`
:host {
min-height: var(--ha-space-10);
}
#icon ::slotted(*) {
color: var(--ha-color-on-neutral-normal);
}
:host([variant="danger"]) #icon ::slotted(*) {
color: var(--ha-color-on-danger-quiet);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dropdown-item": HaDropdownItem;
}
}

View File

@@ -0,0 +1,45 @@
import Dropdown from "@home-assistant/webawesome/dist/components/dropdown/dropdown";
import { css, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
/**
* Home Assistant dropdown component
*
* @element ha-dropdown
* @extends {Dropdown}
*
* @summary
* A stylable dropdown component supporting Home Assistant theming, variants, and appearances based on webawesome dropdown.
*
*/
@customElement("ha-dropdown")
export class HaDropdown extends Dropdown {
@property({ attribute: false }) dropdownTag = "ha-dropdown";
@property({ attribute: false }) dropdownItemTag = "ha-dropdown-item";
static get styles(): CSSResultGroup {
return [
Dropdown.styles,
css`
:host {
font-size: var(--ha-dropdown-font-size, var(--ha-font-size-m));
--wa-color-surface-raised: var(
--card-background-color,
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
);
}
#menu {
padding: var(--ha-space-1);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dropdown": HaDropdown;
}
}

View File

@@ -109,7 +109,10 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label style=${color ? `--color: ${color}` : ""}>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon
slot="icon"

View File

@@ -383,8 +383,9 @@ export class HaFloorPicker extends LitElement {
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.floor-picker.no_match"
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.floor-picker.no_floors"
)}
.placeholder=${placeholder}
.value=${this.value}
@@ -444,6 +445,11 @@ export class HaFloorPicker extends LitElement {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.floor-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
declare global {

View File

@@ -25,9 +25,6 @@ import "./ha-svg-icon";
export class HaGenericPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@@ -49,8 +46,11 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: false, type: Array })
public getItems?: () => PickerComboBoxItem[];
@property({ attribute: false })
public getItems?: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -64,8 +64,11 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
@property({ attribute: false })
public notFoundLabel?: string | ((search: string) => string);
@property({ attribute: "empty-label" })
public emptyLabel?: string;
@property({ attribute: "popover-placement" })
public popoverPlacement:
@@ -85,6 +88,25 @@ export class HaGenericPicker extends LitElement {
/** If set picker shows an add button instead of textbox when value isn't set */
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
@property({ attribute: false }) public sections?: (
| {
id: string;
label: string;
}
| "separator"
)[];
@property({ attribute: false }) public sectionTitleFunction?: (listInfo: {
firstIndex: number;
lastIndex: number;
firstItem: PickerComboBoxItem | string;
secondItem: PickerComboBoxItem | string;
itemsCount: number;
}) => string | undefined;
@property({ attribute: "selected-section" }) public selectedSection?: string;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -97,6 +119,11 @@ export class HaGenericPicker extends LitElement {
@state() private _openedNarrow = false;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
private _narrow = false;
// helper to set new value after closing picker, to avoid flicker
@@ -189,16 +216,19 @@ export class HaGenericPicker extends LitElement {
<ha-picker-combo-box
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel ??
(this.hass?.localize("ui.common.search") || "Search")}
.label=${this.searchLabel}
.value=${this.value}
@value-changed=${this._valueChanged}
.rowRenderer=${this.rowRenderer}
.notFoundLabel=${this.notFoundLabel}
.emptyLabel=${this.emptyLabel}
.getItems=${this.getItems}
.getAdditionalItems=${this.getAdditionalItems}
.searchFn=${this.searchFn}
.mode=${dialogMode ? "dialog" : "popover"}
.sections=${this.sections}
.sectionTitleFunction=${this.sectionTitleFunction}
.selectedSection=${this.selectedSection}
></ha-picker-combo-box>
`;
}

View File

@@ -60,6 +60,10 @@ class HaHLSPlayer extends LitElement {
private static streamCount = 0;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) {
this._cleanUp();
} else {

View File

@@ -224,8 +224,9 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.label-picker.no_match"
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.label-picker.no_labels"
)}
.addButtonLabel=${this.hass.localize("ui.components.label-picker.add")}
.placeholder=${placeholder}
@@ -288,6 +289,11 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
fireEvent(this, "change");
}, 0);
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.label-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
declare global {

View File

@@ -1,17 +1,32 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { uid } from "../common/util/uid";
import "./ha-tooltip";
@customElement("ha-label")
class HaLabel extends LitElement {
@property({ type: Boolean, reflect: true }) dense = false;
@property({ attribute: "description" })
public description?: string;
private _elementId = "label-" + uid();
protected render(): TemplateResult {
return html`
<span class="content">
<slot name="icon"></slot>
<slot></slot>
</span>
<ha-tooltip
.for=${this._elementId}
.disabled=${!this.description?.trim()}
>
${this.description}
</ha-tooltip>
<div class="container" .id=${this._elementId}>
<span class="content">
<slot name="icon"></slot>
<slot></slot>
</span>
</div>
`;
}
@@ -36,9 +51,7 @@ class HaLabel extends LitElement {
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
letter-spacing: 0.1px;
vertical-align: middle;
height: 32px;
padding: 0 16px;
border-radius: var(--ha-border-radius-xl);
color: var(--ha-label-text-color);
--mdc-icon-size: 12px;
@@ -66,15 +79,24 @@ class HaLabel extends LitElement {
display: flex;
}
.container {
display: flex;
position: relative;
height: 100%;
padding: 0 16px;
}
span {
display: inline-flex;
}
:host([dense]) {
height: 20px;
padding: 0 12px;
border-radius: var(--ha-border-radius-md);
}
:host([dense]) .container {
padding: 0 12px;
}
:host([dense]) ::slotted([slot="icon"]) {
margin-right: 4px;
margin-left: -4px;

View File

@@ -21,6 +21,7 @@ import "./chips/ha-input-chip";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-label-picker";
import type { HaLabelPicker } from "./ha-label-picker";
import "./ha-tooltip";
@customElement("ha-labels-picker")
export class HaLabelsPicker extends SubscribeMixin(LitElement) {
@@ -142,9 +143,17 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
const color = label?.color
? computeCssColor(label.color)
: undefined;
const elementId = "label-" + label.label_id;
return html`
<ha-tooltip
.for=${elementId}
.disabled=${!label?.description?.trim()}
>
${label?.description}
</ha-tooltip>
<ha-input-chip
.item=${label}
.id=${elementId}
@remove=${this._removeItem}
@click=${this._openDetail}
.disabled=${this.disabled}

View File

@@ -125,9 +125,10 @@ export class HaLanguagePicker extends LitElement {
.hass=${this.hass}
.autofocus=${this.autofocus}
popover-placement="bottom-end"
.notFoundLabel=${this.hass?.localize(
"ui.components.language-picker.no_match"
)}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass?.localize(
"ui.components.language-picker.no_languages"
) || "No languages available"}
.placeholder=${this.label ??
(this.hass?.localize("ui.components.language-picker.language") ||
"Language")}
@@ -172,6 +173,15 @@ export class HaLanguagePicker extends LitElement {
this.value = ev.detail.value;
fireEvent(this, "value-changed", { value: this.value });
}
private _notFoundLabel = (search: string) => {
const term = html`<b>${search}</b>`;
return this.hass
? this.hass.localize("ui.components.language-picker.no_match", {
term,
})
: html`No languages found for ${term}`;
};
}
declare global {

View File

@@ -175,10 +175,10 @@ export class HaMdDialog extends Dialog {
}
.container {
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
margin-top: var(--safe-area-inset-top, var(--ha-space-0));
margin-bottom: var(--safe-area-inset-bottom, var(--ha-space-0));
margin-left: var(--safe-area-inset-left, var(--ha-space-0));
margin-right: var(--safe-area-inset-right, var(--ha-space-0));
}
}

View File

@@ -1,7 +1,8 @@
import { ListItemEl } from "@material/web/list/internal/listitem/list-item";
import { styles } from "@material/web/list/internal/listitem/list-item-styles";
import { css } from "lit";
import { css, html, nothing, type TemplateResult } from "lit";
import { customElement } from "lit/decorators";
import "./ha-ripple";
export const haMdListStyles = [
styles,
@@ -25,6 +26,18 @@ export const haMdListStyles = [
@customElement("ha-md-list-item")
export class HaMdListItem extends ListItemEl {
static override styles = haMdListStyles;
protected renderRipple(): TemplateResult | typeof nothing {
if (this.type === "text") {
return nothing;
}
return html`<ha-ripple
part="ripple"
for="item"
?disabled=${this.disabled && this.type !== "link"}
></ha-ripple>`;
}
}
declare global {

View File

@@ -6,6 +6,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { titleCase } from "../common/string/title-case";
import { fetchConfig } from "../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../data/lovelace/config/view";
import { getDefaultPanelUrlPath } from "../data/panel";
import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
@@ -44,7 +45,7 @@ const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({
path: `/${panel.url_path}`,
icon: panel.icon ?? "mdi:view-dashboard",
title:
panel.url_path === hass.defaultPanel
panel.url_path === getDefaultPanelUrlPath(hass)
? hass.localize("panel.states")
: hass.localize(`panel.${panel.title}`) ||
panel.title ||

View File

@@ -1,6 +1,6 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiMagnify } from "@mdi/js";
import { mdiMagnify, mdiMinusBoxOutline } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -14,11 +14,12 @@ import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { HaFuse } from "../resources/fuse";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-filter-chip";
import "./ha-combo-box-item";
import "./ha-icon";
import "./ha-textfield";
@@ -27,28 +28,18 @@ import type { HaTextField } from "./ha-textfield";
export interface PickerComboBoxItem {
id: string;
primary: string;
a11y_label?: string;
secondary?: string;
search_labels?: string[];
sorting_label?: string;
icon_path?: string;
icon?: string;
}
// Hack to force empty label to always display empty value by default in the search field
export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem {
a11y_label: string;
}
const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___";
const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
item
) => html`
<ha-combo-box-item
.type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"}
compact
>
<ha-combo-box-item type="button" compact>
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
@@ -87,8 +78,11 @@ export class HaPickerComboBox extends LitElement {
@state() private _listScrolled = false;
@property({ attribute: false, type: Array })
public getItems?: () => PickerComboBoxItem[];
@property({ attribute: false })
public getItems?: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -96,21 +90,45 @@ export class HaPickerComboBox extends LitElement {
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
@property({ attribute: false })
public notFoundLabel?: string | ((search: string) => string);
@property({ attribute: "empty-label" })
public emptyLabel?: string;
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
@property({ attribute: false }) public sections?: (
| {
id: string;
label: string;
}
| "separator"
)[];
@property({ attribute: false }) public sectionTitleFunction?: (listInfo: {
firstIndex: number;
lastIndex: number;
firstItem: PickerComboBoxItem | string;
secondItem: PickerComboBoxItem | string;
itemsCount: number;
}) => string | undefined;
@property({ attribute: "selected-section" }) public selectedSection?: string;
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
@query("ha-textfield") private _searchFieldElement?: HaTextField;
@state() private _items: PickerComboBoxItemWithLabel[] = [];
@state() private _items: (PickerComboBoxItem | string)[] = [];
private _allItems: PickerComboBoxItemWithLabel[] = [];
@state() private _sectionTitle?: string;
private _allItems: (PickerComboBoxItem | string)[] = [];
private _selectedItemIndex = -1;
@@ -121,6 +139,8 @@ export class HaPickerComboBox extends LitElement {
private _removeKeyboardShortcuts?: () => void;
private _search = "";
protected firstUpdated() {
this._registerKeyboardShortcuts();
}
@@ -145,74 +165,142 @@ export class HaPickerComboBox extends LitElement {
"Search"}
@input=${this._filterChanged}
></ha-textfield>
${this._renderSectionButtons()}
${this.sections?.length
? html`
<div class="section-title-wrapper">
<div
class="section-title ${!this.selectedSection &&
this._sectionTitle
? "show"
: ""}"
>
${this._sectionTitle}
</div>
</div>
`
: nothing}
<lit-virtualizer
@scroll=${this._onScrollList}
.keyFunction=${this._keyFunction}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer> `;
}
private _defaultNotFoundItem = memoizeOne(
(
label: this["notFoundLabel"],
localize?: LocalizeFunc
): PickerComboBoxItemWithLabel => ({
id: NO_MATCHING_ITEMS_FOUND_ID,
primary:
label ||
(localize && localize("ui.components.combo-box.no_match")) ||
"No matching items found",
icon_path: mdiMagnify,
a11y_label:
label ||
(localize && localize("ui.components.combo-box.no_match")) ||
"No matching items found",
})
);
private _renderSectionButtons() {
if (!this.sections || this.sections.length === 0) {
return nothing;
}
private _getAdditionalItems = (searchString?: string) => {
const items = this.getAdditionalItems?.(searchString) || [];
return html`
<ha-chip-set class="sections">
${this.sections.map((section) =>
section === "separator"
? html`<div class="separator"></div>`
: html`<ha-filter-chip
@click=${this._toggleSection}
.section-id=${section.id}
.selected=${this.selectedSection === section.id}
.label=${section.label}
>
</ha-filter-chip>`
)}
</ha-chip-set>
`;
}
return items.map<PickerComboBoxItemWithLabel>((item) => ({
...item,
a11y_label: item.a11y_label || item.primary,
}));
};
@eventOptions({ passive: true })
private _visibilityChanged(ev) {
if (
this._virtualizerElement &&
this.sectionTitleFunction &&
this.sections?.length
) {
const firstItem = this._virtualizerElement.items[ev.first];
const secondItem = this._virtualizerElement.items[ev.first + 1];
this._sectionTitle = this.sectionTitleFunction({
firstIndex: ev.first,
lastIndex: ev.last,
firstItem: firstItem as PickerComboBoxItem | string,
secondItem: secondItem as PickerComboBoxItem | string,
itemsCount: this._virtualizerElement.items.length,
});
}
}
private _getItems = (): PickerComboBoxItemWithLabel[] => {
const items = this.getItems ? this.getItems() : [];
private _getAdditionalItems = (searchString?: string) =>
this.getAdditionalItems?.(searchString) || [];
const sortedItems = items
.map<PickerComboBoxItemWithLabel>((item) => ({
...item,
a11y_label: item.a11y_label || item.primary,
}))
.sort((entityA, entityB) =>
private _getItems = () => {
let items = [
...(this.getItems
? this.getItems(this._search, this.selectedSection)
: []),
];
if (!this.sections?.length) {
items = items.sort((entityA, entityB) =>
caseInsensitiveStringCompare(
entityA.sorting_label!,
entityB.sorting_label!,
(entityA as PickerComboBoxItem).sorting_label!,
(entityB as PickerComboBoxItem).sorting_label!,
this.hass?.locale.language ?? navigator.language
)
);
}
if (!sortedItems.length) {
sortedItems.push(
this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
);
if (!items.length) {
items.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
sortedItems.push(...additionalItems);
return sortedItems;
items.push(...additionalItems);
if (this.mode === "dialog") {
items.push("padding"); // padding for safe area inset
}
return items;
};
private _renderItem = (item: PickerComboBoxItem, index: number) => {
private _renderItem = (item: PickerComboBoxItem | string, index: number) => {
if (item === "padding") {
return html`<div class="bottom-padding"></div>`;
}
if (item === NO_ITEMS_AVAILABLE_ID) {
return html`
<div class="combo-box-row">
<ha-combo-box-item type="text" compact>
<ha-svg-icon
slot="start"
.path=${this._search ? mdiMagnify : mdiMinusBoxOutline}
></ha-svg-icon>
<span slot="headline"
>${this._search
? typeof this.notFoundLabel === "function"
? this.notFoundLabel(this._search)
: this.notFoundLabel ||
this.hass?.localize("ui.components.combo-box.no_match") ||
"No matching items found"
: this.emptyLabel ||
this.hass?.localize("ui.components.combo-box.no_items") ||
"No items available"}</span
>
</ha-combo-box-item>
</div>
`;
}
if (typeof item === "string") {
return html`<div class="title">${item}</div>`;
}
const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER;
return html`<div
id=${`list-item-${index}`}
@@ -221,9 +309,7 @@ export class HaPickerComboBox extends LitElement {
.index=${index}
@click=${this._valueSelected}
>
${item.id === NO_MATCHING_ITEMS_FOUND_ID
? DEFAULT_ROW_RENDERER(item, index)
: renderer(item, index)}
${renderer(item, index)}
</div>`;
};
@@ -242,10 +328,6 @@ export class HaPickerComboBox extends LitElement {
const value = (ev.currentTarget as any).value as string;
const newValue = value?.trim();
if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
return;
}
fireEvent(this, "value-changed", { value: newValue });
};
@@ -256,51 +338,83 @@ export class HaPickerComboBox extends LitElement {
private _filterChanged = (ev: Event) => {
const textfield = ev.target as HaTextField;
const searchString = textfield.value.trim();
this._search = searchString;
if (!searchString) {
this._items = this._allItems;
return;
}
if (this.sections?.length) {
this._items = this._getItems();
} else {
if (!searchString) {
this._items = this._allItems;
return;
}
const index = this._fuseIndex(this._allItems);
const fuse = new HaFuse(
this._allItems,
{
shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2),
},
index
);
const index = this._fuseIndex(this._allItems as PickerComboBoxItem[]);
const fuse = new HaFuse(
this._allItems as PickerComboBoxItem[],
{
shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2),
},
index
);
const results = fuse.multiTermsSearch(searchString);
let filteredItems = this._allItems as PickerComboBoxItem[];
if (results) {
const items = results.map((result) => result.item);
if (items.length === 0) {
items.push(
this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
const results = fuse.multiTermsSearch(searchString);
let filteredItems = [...this._allItems];
if (results) {
const items: (PickerComboBoxItem | string)[] = results.map(
(result) => result.item
);
if (!items.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
items.push(...additionalItems);
filteredItems = items;
}
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
filteredItems as PickerComboBoxItem[],
this._allItems as PickerComboBoxItem[]
);
}
const additionalItems = this._getAdditionalItems(searchString);
items.push(...additionalItems);
filteredItems = items;
this._items = filteredItems as PickerComboBoxItem[];
}
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
filteredItems,
this._allItems
);
}
this._items = filteredItems as PickerComboBoxItemWithLabel[];
this._selectedItemIndex = -1;
if (this._virtualizerElement) {
this._virtualizerElement.scrollTo(0, 0);
}
};
private _toggleSection(ev: Event) {
ev.stopPropagation();
this._resetSelectedItem();
this._sectionTitle = undefined;
const section = (ev.target as HTMLElement)["section-id"] as string;
if (!section) {
return;
}
if (this.selectedSection === section) {
this.selectedSection = undefined;
} else {
this.selectedSection = section;
}
this._items = this._getItems();
// Reset scroll position when filter changes
if (this._virtualizerElement) {
this._virtualizerElement.scrollToIndex(0);
}
}
private _registerKeyboardShortcuts() {
this._removeKeyboardShortcuts = tinykeys(this, {
ArrowUp: this._selectPreviousItem,
@@ -344,7 +458,7 @@ export class HaPickerComboBox extends LitElement {
return;
}
if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) {
if (typeof items[nextIndex] === "string") {
// Skip titles, padding and empty search
if (nextIndex === maxItems) {
return;
@@ -373,7 +487,7 @@ export class HaPickerComboBox extends LitElement {
return;
}
if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) {
if (typeof items[nextIndex] === "string") {
// Skip titles, padding and empty search
if (nextIndex === 0) {
return;
@@ -395,13 +509,6 @@ export class HaPickerComboBox extends LitElement {
const nextIndex = 0;
if (
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
NO_MATCHING_ITEMS_FOUND_ID
) {
return;
}
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1;
} else {
@@ -419,13 +526,6 @@ export class HaPickerComboBox extends LitElement {
const nextIndex = this._virtualizerElement.items.length - 1;
if (
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
NO_MATCHING_ITEMS_FOUND_ID
) {
return;
}
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex - 1;
} else {
@@ -453,10 +553,7 @@ export class HaPickerComboBox extends LitElement {
ev.stopPropagation();
const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
if (
this._virtualizerElement?.items.length === 1 &&
firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID
) {
if (this._virtualizerElement?.items.length === 1) {
fireEvent(this, "value-changed", {
value: firstItem.id,
});
@@ -472,7 +569,7 @@ export class HaPickerComboBox extends LitElement {
const item = this._virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) {
if (item) {
fireEvent(this, "value-changed", { value: item.id });
}
};
@@ -484,6 +581,9 @@ export class HaPickerComboBox extends LitElement {
this._selectedItemIndex = -1;
}
private _keyFunction = (item: PickerComboBoxItem | string) =>
typeof item === "string" ? item : item.id;
static styles = [
haStyleScrollbar,
css`
@@ -558,6 +658,80 @@ export class HaPickerComboBox extends LitElement {
background-color: var(--ha-color-fill-neutral-normal-hover);
}
}
.sections {
display: flex;
flex-wrap: nowrap;
gap: var(--ha-space-2);
padding: var(--ha-space-3) var(--ha-space-3);
overflow: auto;
}
:host([mode="dialog"]) .sections {
padding: var(--ha-space-3) var(--ha-space-4);
}
.sections ha-filter-chip {
flex-shrink: 0;
--md-filter-chip-selected-container-color: var(
--ha-color-fill-primary-normal-hover
);
color: var(--primary-color);
}
.sections .separator {
height: var(--ha-space-8);
width: 0;
border: 1px solid var(--ha-color-border-neutral-quiet);
}
.section-title,
.title {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);
display: flex;
align-items: center;
}
.title {
width: 100%;
}
:host([mode="dialog"]) .title {
padding: var(--ha-space-1) var(--ha-space-4);
}
:host([mode="dialog"]) ha-textfield {
padding: 0 var(--ha-space-4);
}
.section-title-wrapper {
height: 0;
position: relative;
}
.section-title {
opacity: 0;
position: absolute;
top: 1px;
width: calc(100% - var(--ha-space-8));
}
.section-title.show {
opacity: 1;
z-index: 1;
}
.empty-search {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
padding: var(--ha-space-3);
}
`,
];
}

View File

@@ -1,6 +1,8 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import type { StateSelector } from "../../data/selector";
import { extractFromTarget } from "../../data/target";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
import "../entity/ha-entity-state-picker";
@@ -25,15 +27,29 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public context?: {
filter_attribute?: string;
filter_entity?: string | string[];
filter_target?: HassServiceTarget;
};
@state() private _entityIds?: string | string[];
willUpdate(changedProps) {
if (changedProps.has("selector") || changedProps.has("context")) {
this._resolveEntityIds(
this.selector.state?.entity_id,
this.context?.filter_entity,
this.context?.filter_target
).then((entityIds) => {
this._entityIds = entityIds;
});
}
}
protected render() {
if (this.selector.state?.multiple) {
return html`
<ha-entity-states-picker
.hass=${this.hass}
.entityId=${this.selector.state?.entity_id ||
this.context?.filter_entity}
.entityId=${this._entityIds}
.attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options}
@@ -50,8 +66,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
return html`
<ha-entity-state-picker
.hass=${this.hass}
.entityId=${this.selector.state?.entity_id ||
this.context?.filter_entity}
.entityId=${this._entityIds}
.attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options}
@@ -65,6 +80,24 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
></ha-entity-state-picker>
`;
}
private async _resolveEntityIds(
selectorEntityId: string | string[] | undefined,
contextFilterEntity: string | string[] | undefined,
contextFilterTarget: HassServiceTarget | undefined
): Promise<string | string[] | undefined> {
if (selectorEntityId !== undefined) {
return selectorEntityId;
}
if (contextFilterEntity !== undefined) {
return contextFilterEntity;
}
if (contextFilterTarget !== undefined) {
const result = await extractFromTarget(this.hass, contextFilterTarget);
return result.referenced_entities;
}
return undefined;
}
}
declare global {

View File

@@ -33,6 +33,7 @@ import type { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url";
import "./ha-checkbox";
import "./ha-icon-button";
import "./ha-markdown";
import "./ha-selector/ha-selector";
import "./ha-service-picker";
import "./ha-service-section-icon";
@@ -684,10 +685,14 @@ export class HaServiceControl extends LitElement {
dataField.key}</span
>
<span slot="description"
>${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
) || dataField?.description}</span
>
><ha-markdown
breaks
allow-svg
.content=${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
) || dataField?.description}
></ha-markdown>
</span>
<ha-selector
.context=${this._selectorContext(targetEntities)}
.disabled=${this.disabled ||

View File

@@ -33,6 +33,7 @@ import { computeRTL } from "../common/util/compute_rtl";
import { throttle } from "../common/util/throttle";
import { subscribeFrontendUserData } from "../data/frontend";
import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
import { getDefaultPanelUrlPath } from "../data/panel";
import type { PersistentNotification } from "../data/persistent_notification";
import { subscribeNotifications } from "../data/persistent_notification";
import { subscribeRepairsIssueRegistry } from "../data/repairs";
@@ -142,7 +143,7 @@ const defaultPanelSorter = (
export const computePanels = memoizeOne(
(
panels: HomeAssistant["panels"],
defaultPanel: HomeAssistant["defaultPanel"],
defaultPanel: string,
panelsOrder: string[],
hiddenPanels: string[],
locale: HomeAssistant["locale"]
@@ -157,7 +158,9 @@ export const computePanels = memoizeOne(
Object.values(panels).forEach((panel) => {
if (
hiddenPanels.includes(panel.url_path) ||
(!panel.title && panel.url_path !== defaultPanel)
(!panel.title && panel.url_path !== defaultPanel) ||
(panel.default_visible === false &&
!panelsOrder.includes(panel.url_path))
) {
return;
}
@@ -296,7 +299,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
hass.localize !== oldHass.localize ||
hass.locale !== oldHass.locale ||
hass.states !== oldHass.states ||
hass.defaultPanel !== oldHass.defaultPanel ||
hass.userData !== oldHass.userData ||
hass.systemData !== oldHass.systemData ||
hass.connected !== oldHass.connected
);
}
@@ -399,9 +403,11 @@ class HaSidebar extends SubscribeMixin(LitElement) {
`;
}
const defaultPanel = getDefaultPanelUrlPath(this.hass);
const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels,
this.hass.defaultPanel,
defaultPanel,
this._panelOrder,
this._hiddenPanels,
this.hass.locale
@@ -416,23 +422,27 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown}
>
${this._renderPanels(beforeSpacer, selectedPanel)}
${this._renderPanels(beforeSpacer, selectedPanel, defaultPanel)}
${this._renderSpacer()}
${this._renderPanels(afterSpacer, selectedPanel)}
${this._renderPanels(afterSpacer, selectedPanel, defaultPanel)}
${this._renderExternalConfiguration()}
</ha-md-list>
`;
}
private _renderPanels(panels: PanelInfo[], selectedPanel: string) {
private _renderPanels(
panels: PanelInfo[],
selectedPanel: string,
defaultPanel: string
) {
return panels.map((panel) =>
this._renderPanel(
panel.url_path,
panel.url_path === this.hass.defaultPanel
panel.url_path === defaultPanel
? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title,
panel.icon,
panel.url_path === this.hass.defaultPanel && !panel.icon
panel.url_path === defaultPanel && !panel.icon
? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path]

View File

@@ -1,15 +1,31 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume } from "@lit/context";
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import { mdiPlaylistPlus } from "@mdi/js";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import Fuse from "fuse.js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing, unsafeCSS } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { isValidEntityId } from "../common/entity/valid_entity_id";
import { computeRTL } from "../common/util/compute_rtl";
import {
getAreasAndFloors,
type AreaFloorValue,
type FloorComboBoxItem,
} from "../data/area_floor";
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
import { labelsContext } from "../data/context";
import { getDevices, type DevicePickerItem } from "../data/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity";
import { getEntities, type EntityComboBoxItem } from "../data/entity_registry";
import { domainToName } from "../data/integration";
import { getLabels, type LabelRegistryEntry } from "../data/label_registry";
import {
areaMeetsFilter,
deviceMeetsFilter,
@@ -18,18 +34,23 @@ import {
type TargetTypeFloorless,
} from "../data/target";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { isHelperDomain } from "../panels/config/helpers/const";
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
import { HaFuse } from "../resources/fuse";
import type { HomeAssistant } from "../types";
import { brandsUrl } from "../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-input-helper-text";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./target-picker/ha-target-picker-item-group";
import "./target-picker/ha-target-picker-selector";
import type { HaTargetPickerSelector } from "./target-picker/ha-target-picker-selector";
import "./target-picker/ha-target-picker-value-chip";
const EMPTY_SEARCH = "___EMPTY_SEARCH___";
const SEPARATOR = "________";
const CREATE_ID = "___create-new-entity___";
@customElement("ha-target-picker")
export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -68,23 +89,54 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
@state() private _open = false;
@state() private _selectedSection?: TargetTypeFloorless;
@state() private _addTargetWidth = 0;
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _narrow = false;
@state() private _pickerFilter?: TargetTypeFloorless;
@state() private _pickerWrapperOpen = false;
@query(".add-target-wrapper") private _addTargetWrapper?: HTMLDivElement;
@query("ha-target-picker-selector")
private _targetPickerSelectorElement?: HaTargetPickerSelector;
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
private _newTarget?: { type: TargetType; id: string };
private _getDevicesMemoized = memoizeOne(getDevices);
private _getLabelsMemoized = memoizeOne(getLabels);
private _getEntitiesMemoized = memoizeOne(getEntities);
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
private get _showEntityId() {
return this.hass.userData?.showEntityIdPicker;
}
private _fuseIndexes = {
area: memoizeOne((states: FloorComboBoxItem[]) =>
this._createFuseIndex(states)
),
entity: memoizeOne((states: EntityComboBoxItem[]) =>
this._createFuseIndex(states)
),
device: memoizeOne((states: DevicePickerItem[]) =>
this._createFuseIndex(states)
),
label: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
),
};
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._loadConfigEntries();
}
}
private _createFuseIndex = (states) =>
Fuse.createIndex(["search_labels"], states);
protected render() {
if (this.addOnTop) {
return html` ${this._renderPicker()} ${this._renderItems()} `;
@@ -289,137 +341,63 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
private _renderPicker() {
const sections = [
{
id: "entity",
label: this.hass.localize("ui.components.target-picker.type.entities"),
},
{
id: "device",
label: this.hass.localize("ui.components.target-picker.type.devices"),
},
{
id: "area",
label: this.hass.localize("ui.components.target-picker.type.areas"),
},
"separator" as const,
{
id: "label",
label: this.hass.localize("ui.components.target-picker.type.labels"),
},
];
return html`
<div class="add-target-wrapper">
<ha-button
id="add-target-button"
size="small"
appearance="filled"
@click=${this._showPicker}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.autofocus=${this.autofocus}
.helper=${this.helper}
.sections=${sections}
.notFoundLabel=${this._noTargetFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.target-picker.no_targets"
)}
.sectionTitleFunction=${this._sectionTitleFunction}
.selectedSection=${this._selectedSection}
.rowRenderer=${this._renderRow}
.getItems=${this._getItems}
@value-changed=${this._targetPicked}
.addButtonLabel=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
.getAdditionalItems=${this._getAdditionalItems}
>
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.components.target-picker.add_target")}
</ha-button>
${!this._narrow && (this._pickerWrapperOpen || this._open)
? html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._addTargetWidth}px;"
without-arrow
distance="-4"
placement="bottom-start"
for="add-target-button"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._showSelector}
@wa-after-hide=${this._hidePicker}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
>
${this._renderTargetSelector()}
</wa-popover>
`
: this._pickerWrapperOpen || this._open
? html`<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._showSelector}
@closed=${this._hidePicker}
role="dialog"
aria-modal="true"
aria-label=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
>
${this._renderTargetSelector(true)}
</ha-bottom-sheet>`
: nothing}
</ha-generic-picker>
</div>
${this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing}
`;
}
connectedCallback() {
super.connectedCallback();
this._handleResize();
window.addEventListener("resize", this._handleResize);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this._handleResize);
}
private _handleResize = () => {
this._narrow =
window.matchMedia("(max-width: 870px)").matches ||
window.matchMedia("(max-height: 500px)").matches;
};
private _showPicker() {
this._addTargetWidth = this._addTargetWrapper?.offsetWidth || 0;
this._pickerWrapperOpen = true;
}
// wait for drawer animation to finish
private _showSelector = () => {
this._open = true;
requestAnimationFrame(() => {
this._targetPickerSelectorElement?.focus();
});
};
private _handleUpdatePickerFilter(
ev: CustomEvent<TargetTypeFloorless | undefined>
) {
this._updatePickerFilter(
typeof ev.detail === "string" ? ev.detail : undefined
);
}
private _updatePickerFilter = (filter?: TargetTypeFloorless) => {
this._pickerFilter = filter;
};
private _hidePicker(ev) {
private _targetPicked(ev: CustomEvent<{ value: string }>) {
ev.stopPropagation();
this._open = false;
this._pickerWrapperOpen = false;
if (this._newTarget) {
this._addTarget(this._newTarget.id, this._newTarget.type);
this._newTarget = undefined;
const value = ev.detail.value;
if (value.startsWith(CREATE_ID)) {
this._createNewDomainElement(value.substring(CREATE_ID.length));
return;
}
}
private _renderTargetSelector(dialogMode = false) {
if (!this._open) {
return nothing;
}
return html`
<ha-target-picker-selector
.hass=${this.hass}
@filter-type-changed=${this._handleUpdatePickerFilter}
.filterType=${this._pickerFilter}
@target-picked=${this._handleTargetPicked}
@create-domain-picked=${this._handleCreateDomain}
.targetValue=${this.value}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.createDomains=${this.createDomains}
.mode=${dialogMode ? "dialog" : "popover"}
></ha-target-picker-selector>
`;
const [type, id] = ev.detail.value.split(SEPARATOR);
this._addTarget(id, type as TargetType);
}
private _addTarget(id: string, type: TargetType) {
@@ -454,26 +432,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
?.removeAttribute("collapsed");
}
private _handleTargetPicked = async (
ev: CustomEvent<{ type: TargetType; id: string }>
) => {
ev.stopPropagation();
this._pickerWrapperOpen = false;
if (!ev.detail.type || !ev.detail.id) {
return;
}
// save new target temporarily to add it after dialog closes
this._newTarget = ev.detail;
};
private _handleCreateDomain = (ev: CustomEvent<string>) => {
this._pickerWrapperOpen = false;
const domain = ev.detail;
private _createNewDomainElement = (domain: string) => {
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
@@ -675,6 +634,459 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return undefined;
}
private _getRowType = (
item:
| PickerComboBoxItem
| (FloorComboBoxItem & { last?: boolean | undefined })
| EntityComboBoxItem
| DevicePickerItem
) => {
if (
(item as FloorComboBoxItem).type === "area" ||
(item as FloorComboBoxItem).type === "floor"
) {
return (item as FloorComboBoxItem).type;
}
if ("domain" in item) {
return "device";
}
if ("stateObj" in item) {
return "entity";
}
if (item.id === EMPTY_SEARCH) {
return "empty";
}
return "label";
};
private _sectionTitleFunction = ({
firstIndex,
lastIndex,
firstItem,
secondItem,
itemsCount,
}: {
firstIndex: number;
lastIndex: number;
firstItem: PickerComboBoxItem | string;
secondItem: PickerComboBoxItem | string;
itemsCount: number;
}) => {
if (
firstItem === undefined ||
secondItem === undefined ||
typeof firstItem === "string" ||
(typeof secondItem === "string" && secondItem !== "padding") ||
(firstIndex === 0 && lastIndex === itemsCount - 1)
) {
return undefined;
}
const type = this._getRowType(firstItem as PickerComboBoxItem);
const translationType:
| "areas"
| "entities"
| "devices"
| "labels"
| undefined =
type === "area" || type === "floor"
? "areas"
: type === "entity"
? "entities"
: type && type !== "empty"
? `${type}s`
: undefined;
return translationType
? this.hass.localize(
`ui.components.target-picker.type.${translationType}`
)
: undefined;
};
private _getItems = (searchString: string, section: string) => {
this._selectedSection = section as TargetTypeFloorless | undefined;
return this._getItemsMemoized(
this.hass.localize,
this.entityFilter,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.value,
searchString,
this._configEntryLookup,
this._selectedSection
);
};
private _getItemsMemoized = memoizeOne(
(
localize: HomeAssistant["localize"],
entityFilter: this["entityFilter"],
deviceFilter: this["deviceFilter"],
includeDomains: this["includeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
targetValue: this["value"],
searchTerm: string,
configEntryLookup: Record<string, ConfigEntry>,
filterType?: TargetTypeFloorless
) => {
const items: (
| string
| FloorComboBoxItem
| EntityComboBoxItem
| PickerComboBoxItem
)[] = [];
if (!filterType || filterType === "entity") {
let entityItems = this._getEntitiesMemoized(
this.hass,
includeDomains,
undefined,
entityFilter,
includeDeviceClasses,
undefined,
undefined,
targetValue?.entity_id
? ensureArray(targetValue.entity_id)
: undefined,
undefined,
`entity${SEPARATOR}`
);
if (searchTerm) {
entityItems = this._filterGroup(
"entity",
entityItems,
searchTerm,
(item: EntityComboBoxItem) =>
item.stateObj?.entity_id === searchTerm
) as EntityComboBoxItem[];
}
if (!filterType && entityItems.length) {
// show group title
items.push(localize("ui.components.target-picker.type.entities"));
}
items.push(...entityItems);
}
if (!filterType || filterType === "device") {
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
includeDomains,
undefined,
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.device_id
? ensureArray(targetValue.device_id)
: undefined,
undefined,
`device${SEPARATOR}`
);
if (searchTerm) {
deviceItems = this._filterGroup("device", deviceItems, searchTerm);
}
if (!filterType && deviceItems.length) {
// show group title
items.push(localize("ui.components.target-picker.type.devices"));
}
items.push(...deviceItems);
}
if (!filterType || filterType === "area") {
let areasAndFloors = this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(SEPARATOR)
),
includeDomains,
undefined,
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.area_id ? ensureArray(targetValue.area_id) : undefined,
targetValue?.floor_id ? ensureArray(targetValue.floor_id) : undefined
);
if (searchTerm) {
areasAndFloors = this._filterGroup(
"area",
areasAndFloors,
searchTerm
) as FloorComboBoxItem[];
}
if (!filterType && areasAndFloors.length) {
// show group title
items.push(localize("ui.components.target-picker.type.areas"));
}
items.push(
...areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
) {
return {
...item,
last: true,
};
}
return item;
})
);
}
if (!filterType || filterType === "label") {
let labels = this._getLabelsMemoized(
this.hass,
this._labelRegistry,
includeDomains,
undefined,
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.label_id ? ensureArray(targetValue.label_id) : undefined,
`label${SEPARATOR}`
);
if (searchTerm) {
labels = this._filterGroup("label", labels, searchTerm);
}
if (!filterType && labels.length) {
// show group title
items.push(localize("ui.components.target-picker.type.labels"));
}
items.push(...labels);
}
return items;
}
);
private _filterGroup(
type: TargetType,
items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[],
searchTerm: string,
checkExact?: (
item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem
) => boolean
) {
const fuseIndex = this._fuseIndexes[type](items);
const fuse = new HaFuse(
items,
{
shouldSort: false,
minMatchCharLength: Math.min(searchTerm.length, 2),
},
fuseIndex
);
const results = fuse.multiTermsSearch(searchTerm);
let filteredItems = items;
if (results) {
filteredItems = results.map((result) => result.item);
}
if (!checkExact) {
return filteredItems;
}
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex((item) => checkExact(item));
if (index === -1) {
return filteredItems;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
}
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);
private _getCreateItems = memoizeOne(
(createDomains: this["createDomains"]) => {
if (!createDomains?.length) {
return [];
}
return createDomains.map((domain) => {
const primary = this.hass.localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
: domainToName(this.hass.localize, domain),
}
);
return {
id: CREATE_ID + domain,
primary: primary,
secondary: this.hass.localize(
"ui.components.entity.entity-picker.new_entity"
),
icon_path: mdiPlus,
} satisfies EntityComboBoxItem;
});
}
);
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
private _renderRow = (
item:
| PickerComboBoxItem
| (FloorComboBoxItem & { last?: boolean | undefined })
| EntityComboBoxItem
| DevicePickerItem,
index: number
) => {
if (!item) {
return nothing;
}
const type = this._getRowType(item);
let hasFloor = false;
let rtl = false;
let showEntityId = false;
if (type === "area" || type === "floor") {
item.id = item[type]?.[`${type}_id`];
rtl = computeRTL(this.hass);
hasFloor =
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
}
if (type === "entity") {
showEntityId = !!this._showEntityId;
}
return html`
<ha-combo-box-item
id=${`list-item-${index}`}
tabindex="-1"
.type=${type === "empty" ? "text" : "button"}
class=${type === "empty" ? "empty" : ""}
style=${(item as FloorComboBoxItem).type === "area" && hasFloor
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""}
>
${(item as FloorComboBoxItem).type === "area" && hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "var(--ha-space-0)",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${(
item as FloorComboBoxItem & { last?: boolean | undefined }
).last}
slot="start"
></ha-tree-indicator>
`
: nothing}
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`
: type === "entity" && (item as EntityComboBoxItem).stateObj
? html`
<state-badge
slot="start"
.stateObj=${(item as EntityComboBoxItem).stateObj}
.hass=${this.hass}
></state-badge>
`
: type === "device" && (item as DevicePickerItem).domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl({
domain: (item as DevicePickerItem).domain!,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
})}
/>
`
: type === "floor"
? html`<ha-floor-icon
slot="start"
.floor=${(item as FloorComboBoxItem).floor!}
></ha-floor-icon>`
: type === "area"
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path || mdiTextureBox}
></ha-svg-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${(item as EntityComboBoxItem).stateObj && showEntityId
? html`
<span slot="supporting-text" class="code">
${(item as EntityComboBoxItem).stateObj?.entity_id}
</span>
`
: nothing}
${(item as EntityComboBoxItem).domain_name &&
(type !== "entity" || !showEntityId)
? html`
<div slot="trailing-supporting-text" class="domain">
${(item as EntityComboBoxItem).domain_name}
</div>
`
: nothing}
</ha-combo-box-item>
`;
};
private _noTargetFoundLabel = (search: string) =>
this.hass.localize("ui.components.target-picker.no_target_found", {
term: html`<b>${search}</b>`,
});
static get styles(): CSSResultGroup {
return css`
.add-target-wrapper {
@@ -683,31 +1095,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
margin-top: var(--ha-space-3);
}
wa-popover {
--wa-space-l: var(--ha-space-0);
}
wa-popover::part(body) {
width: min(max(var(--body-width), 336px), 600px);
max-width: min(max(var(--body-width), 336px), 600px);
max-height: 500px;
height: 70vh;
overflow: hidden;
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
ha-bottom-sheet {
--ha-bottom-sheet-height: 90vh;
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
--ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
--ha-bottom-sheet-max-width: 600px;
--ha-bottom-sheet-padding: var(--ha-space-0);
--ha-bottom-sheet-surface-background: var(--card-background-color);
ha-generic-picker {
width: 100%;
}
${unsafeCSS(chipStyles)}

View File

@@ -0,0 +1,97 @@
import {
mdiAvTimer,
mdiCalendar,
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiFormatListBulleted,
mdiGestureDoubleTap,
mdiHomeAssistant,
mdiMapMarker,
mdiMapMarkerRadius,
mdiMessageAlert,
mdiMicrophoneMessage,
mdiNfcVariant,
mdiNumeric,
mdiStateMachine,
mdiSwapHorizontal,
mdiWeatherSunny,
mdiWebhook,
} from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
export const TRIGGER_ICONS = {
calendar: mdiCalendar,
device: mdiDevices,
event: mdiGestureDoubleTap,
state: mdiStateMachine,
geo_location: mdiMapMarker,
homeassistant: mdiHomeAssistant,
mqtt: mdiSwapHorizontal,
numeric_state: mdiNumeric,
sun: mdiWeatherSunny,
conversation: mdiMicrophoneMessage,
tag: mdiNfcVariant,
template: mdiCodeBraces,
time: mdiClockOutline,
time_pattern: mdiAvTimer,
webhook: mdiWebhook,
persistent_notification: mdiMessageAlert,
zone: mdiMapMarkerRadius,
list: mdiFormatListBulleted,
};
@customElement("ha-trigger-icon")
export class HaTriggerIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public trigger?: string;
@property() public icon?: string;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
}
if (!this.trigger) {
return nothing;
}
if (!this.hass) {
return this._renderFallback();
}
const icon = triggerIcon(this.hass, this.trigger).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
const domain = computeDomain(this.trigger!);
return html`
<ha-svg-icon
.path=${TRIGGER_ICONS[this.trigger!] || FALLBACK_DOMAIN_ICONS[domain]}
></ha-svg-icon>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-trigger-icon": HaTriggerIcon;
}
}

View File

@@ -1,6 +1,4 @@
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import {
customElement,
eventOptions,
@@ -8,6 +6,9 @@ import {
query,
state,
} from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { mdiClose } from "@mdi/js";
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
@@ -31,6 +32,8 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
*
* @slot header - Replace the entire header area.
* @slot headerNavigationIcon - Leading header action (e.g. close/back button).
* @slot headerTitle - Custom title content (used when header-title is not set).
* @slot headerSubtitle - Custom subtitle content (used when header-subtitle is not set).
* @slot headerActionItems - Trailing header actions (e.g. buttons, menus).
* @slot - Dialog content body.
* @slot footer - Dialog footer content.
@@ -52,8 +55,8 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* @attr {boolean} open - Controls the dialog open state.
* @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium".
* @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false.
* @attr {string} header-title - Header title text when no custom title slot is provided.
* @attr {string} header-subtitle - Header subtitle text when no custom subtitle slot is provided.
* @attr {string} header-title - Header title text. If not set, the headerTitle slot is used.
* @attr {string} header-subtitle - Header subtitle text. If not set, the headerSubtitle slot is used.
* @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below".
* @attr {boolean} flexcontent - Makes the dialog body a flex container for flexible layouts.
*
@@ -72,6 +75,12 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
export class HaWaDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@property({ attribute: "aria-describedby" })
public ariaDescribedBy?: string;
@property({ type: Boolean, reflect: true })
public open = false;
@@ -81,11 +90,11 @@ export class HaWaDialog extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" })
public preventScrimClose = false;
@property({ type: String, attribute: "header-title" })
public headerTitle = "";
@property({ attribute: "header-title" })
public headerTitle?: string;
@property({ type: String, attribute: "header-subtitle" })
public headerSubtitle = "";
@property({ attribute: "header-subtitle" })
public headerSubtitle?: string;
@property({ type: String, attribute: "header-subtitle-position" })
public headerSubtitlePosition: "above" | "below" = "below";
@@ -117,6 +126,11 @@ export class HaWaDialog extends LitElement {
.open=${this._open}
.lightDismiss=${!this.preventScrimClose}
without-header
aria-labelledby=${ifDefined(
this.ariaLabelledBy ||
(this.headerTitle !== undefined ? "ha-wa-dialog-title" : undefined)
)}
aria-describedby=${ifDefined(this.ariaDescribedBy)}
@wa-show=${this._handleShow}
@wa-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide}
@@ -133,14 +147,14 @@ export class HaWaDialog extends LitElement {
.path=${mdiClose}
></ha-icon-button>
</slot>
${this.headerTitle
? html`<span slot="title" class="title">
${this.headerTitle !== undefined
? html`<span slot="title" class="title" id="ha-wa-dialog-title">
${this.headerTitle}
</span>`
: nothing}
${this.headerSubtitle
: html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle !== undefined
? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: nothing}
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
</slot>

View File

@@ -62,6 +62,10 @@ class HaWebRtcPlayer extends LitElement {
private _candidatesList: RTCIceCandidate[] = [];
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) {
this._cleanUp();
} else {

View File

@@ -34,6 +34,7 @@ class SearchInput extends LitElement {
return html`
<ha-textfield
.autofocus=${this.autofocus}
autocomplete="off"
.label=${this.label || this.hass.localize("ui.common.search")}
.value=${this.filter || ""}
icon

View File

@@ -545,7 +545,7 @@ export class HaTargetPickerItemRow extends LitElement {
name: entityName || deviceName || item,
context,
stateObject,
notFound: !stateObject && item !== "all",
notFound: !stateObject && item !== "all" && item !== "none",
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -128,9 +128,7 @@ class HaUserPicker extends LitElement {
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.user-picker.no_match"
)}
.notFoundLabel=${this._notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
@@ -149,6 +147,11 @@ class HaUserPicker extends LitElement {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.user-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
declare global {

View File

@@ -50,7 +50,7 @@ export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device_id: {},
serviceGroups: {},
dynamicGroups: {},
},
},
{
@@ -117,14 +117,6 @@ export const VIRTUAL_ACTIONS: Partial<
},
} as const;
export const SERVICE_PREFIX = "__SERVICE__";
export const isService = (key: string | undefined): boolean | undefined =>
key?.startsWith(SERVICE_PREFIX);
export const getService = (key: string): string =>
key.substring(SERVICE_PREFIX.length);
export const COLLAPSIBLE_ACTION_ELEMENTS = [
"ha-automation-action-choose",
"ha-automation-action-condition",

View File

@@ -1,3 +1,4 @@
import { getAreasFloorHierarchy } from "../common/areas/areas-floor-hierarchy";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
@@ -12,11 +13,7 @@ import {
} from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry";
import {
floorCompare,
getFloorAreaLookup,
type FloorRegistryEntry,
} from "./floor_registry";
import type { FloorRegistryEntry } from "./floor_registry";
export interface FloorComboBoxItem extends PickerComboBoxItem {
type: "floor" | "area";
@@ -182,68 +179,59 @@ export const getAreasAndFloors = (
);
}
const floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassignedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
const compare = floorCompare(haFloors);
// @ts-ignore
const floorAreaEntries: [
FloorRegistryEntry | undefined,
AreaRegistryEntry[],
][] = Object.entries(floorAreaLookup)
.map(([floorId, floorAreas]) => {
const floor = floors.find((fl) => fl.floor_id === floorId)!;
return [floor, floorAreas] as const;
})
.sort(([floorA], [floorB]) => compare(floorA.floor_id, floorB.floor_id));
const hierarchy = getAreasFloorHierarchy(floors, outputAreas);
const items: FloorComboBoxItem[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) {
const floorName = computeFloorName(floor);
hierarchy.floors.forEach((f) => {
const floor = haFloors[f.id];
const floorAreas = f.areas.map((areaId) => haAreas[areaId]);
const areaSearchLabels = floorAreas
.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return [area.area_id, areaName, ...area.aliases];
})
.flat();
const floorName = computeFloorName(floor);
const areaSearchLabels = floorAreas
.map((area) => {
const areaName = computeAreaName(area);
return [area.area_id, ...(areaName ? [areaName] : []), ...area.aliases];
})
.flat();
items.push({
id: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor",
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
items.push({
id: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor",
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
}
items.push(
...floorAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
const areaName = computeAreaName(area);
return {
id: formatId({ id: area.area_id, type: "area" }),
type: "area" as const,
primary: areaName,
primary: areaName || area.area_id,
area: area,
icon: area.icon || undefined,
search_labels: [area.area_id, areaName, ...area.aliases],
search_labels: [
area.area_id,
...(areaName ? [areaName] : []),
...area.aliases,
],
};
})
);
});
items.push(
...unassignedAreas.map((area) => {
...hierarchy.areas.map((areaId) => {
const area = haAreas[areaId];
const areaName = computeAreaName(area) || area.area_id;
return {
id: formatId({ id: area.area_id, type: "area" }),

View File

@@ -59,6 +59,15 @@ export const deleteAreaRegistryEntry = (hass: HomeAssistant, areaId: string) =>
area_id: areaId,
});
export const reorderAreaRegistryEntries = (
hass: HomeAssistant,
areaIds: string[]
) =>
hass.callWS({
type: "config/area_registry/reorder",
area_ids: areaIds,
});
export const getAreaEntityLookup = (
entities: EntityRegistryEntry[]
): AreaEntityLookup => {

View File

@@ -1,8 +1,10 @@
import type {
HassEntityAttributeBase,
HassEntityBase,
HassServiceTarget,
} from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
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";
@@ -12,10 +14,19 @@ import { CONDITION_BUILDING_BLOCKS } from "./condition";
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import type { Action, Field, MODES } from "./script";
import { migrateAutomationAction } from "./script";
import type { TriggerDescription } from "./trigger";
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
export const AUTOMATION_DEFAULT_MAX = 10;
export const DYNAMIC_PREFIX = "__DYNAMIC__";
export const isDynamic = (key: string | undefined): boolean | undefined =>
key?.startsWith(DYNAMIC_PREFIX);
export const getValueFromDynamic = (key: string): string =>
key.substring(DYNAMIC_PREFIX.length);
export interface AutomationEntity extends HassEntityBase {
attributes: HassEntityAttributeBase & {
id?: string;
@@ -85,6 +96,12 @@ export interface BaseTrigger {
id?: string;
variables?: Record<string, unknown>;
enabled?: boolean;
options?: Record<string, unknown>;
}
export interface PlatformTrigger extends BaseTrigger {
trigger: Exclude<string, LegacyTrigger["trigger"]>;
target?: HassServiceTarget;
}
export interface StateTrigger extends BaseTrigger {
@@ -194,7 +211,7 @@ export interface CalendarTrigger extends BaseTrigger {
offset: string;
}
export type Trigger =
export type LegacyTrigger =
| StateTrigger
| MqttTrigger
| GeoLocationTrigger
@@ -211,8 +228,9 @@ export type Trigger =
| TemplateTrigger
| EventTrigger
| DeviceTrigger
| CalendarTrigger
| TriggerList;
| CalendarTrigger;
export type Trigger = LegacyTrigger | TriggerList | PlatformTrigger;
interface BaseCondition {
condition: string;
@@ -257,13 +275,11 @@ export interface ZoneCondition extends BaseCondition {
zone: string;
}
type Weekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
export interface TimeCondition extends BaseCondition {
condition: "time";
after?: string;
before?: string;
weekday?: Weekday | Weekday[];
weekday?: WeekdayShort | WeekdayShort[];
}
export interface TemplateCondition extends BaseCondition {
@@ -576,6 +592,7 @@ export interface TriggerSidebarConfig extends BaseSidebarConfig {
insertAfter: (value: Trigger | Trigger[]) => boolean;
toggleYamlMode: () => void;
config: Trigger;
description?: TriggerDescription;
yamlMode: boolean;
uiSupported: boolean;
}

View File

@@ -16,8 +16,9 @@ import {
formatListWithAnds,
formatListWithOrs,
} from "../common/string/format-list";
import { hasTemplate } from "../common/string/has-template";
import type { HomeAssistant } from "../types";
import type { Condition, ForDict, Trigger } from "./automation";
import type { Condition, ForDict, LegacyTrigger, Trigger } from "./automation";
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import {
localizeDeviceAutomationCondition,
@@ -25,8 +26,7 @@ import {
} from "./device_automation";
import type { EntityRegistryEntry } from "./entity_registry";
import type { FrontendLocaleData } from "./translation";
import { isTriggerList } from "./trigger";
import { hasTemplate } from "../common/string/has-template";
import { getTriggerDomain, getTriggerObjectId, isTriggerList } from "./trigger";
const triggerTranslationBaseKey =
"ui.panel.config.automation.editor.triggers.type";
@@ -121,6 +121,37 @@ const tryDescribeTrigger = (
return trigger.alias;
}
const description = describeLegacyTrigger(
trigger as LegacyTrigger,
hass,
entityRegistry
);
if (description) {
return description;
}
const triggerType = trigger.trigger;
const domain = getTriggerDomain(trigger.trigger);
const type = getTriggerObjectId(trigger.trigger);
return (
hass.localize(
`component.${domain}.triggers.${type}.description_configured`
) ||
hass.localize(
`ui.panel.config.automation.editor.triggers.type.${triggerType as LegacyTrigger["trigger"]}.label`
) ||
hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`)
);
};
const describeLegacyTrigger = (
trigger: LegacyTrigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
) => {
// Event Trigger
if (trigger.trigger === "event" && trigger.event_type) {
const eventTypes: string[] = [];
@@ -802,13 +833,7 @@ const tryDescribeTrigger = (
}
);
}
return (
hass.localize(
`ui.panel.config.automation.editor.triggers.type.${trigger.trigger}.label`
) ||
hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`)
);
return undefined;
};
export const describeCondition = (

View File

@@ -31,6 +31,7 @@ export interface CalendarEventData {
dtend: string;
rrule?: string;
description?: string;
location?: string;
}
export interface CalendarEventMutableParams {
@@ -39,6 +40,7 @@ export interface CalendarEventMutableParams {
dtend: string;
rrule?: string;
description?: string;
location?: string;
}
// The scope of a delete/update for a recurring event
@@ -96,6 +98,7 @@ export const fetchCalendarEvents = async (
uid: ev.uid,
summary: ev.summary,
description: ev.description,
location: ev.location,
dtstart: eventStart,
dtend: eventEnd,
recurrence_id: ev.recurrence_id,

View File

@@ -186,7 +186,8 @@ export const getDevices = (
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeDevices?: string[],
value?: string
value?: string,
idPrefix = ""
): DevicePickerItem[] => {
const devices = Object.values(hass.devices);
const entities = Object.values(hass.entities);
@@ -298,7 +299,7 @@ export const getDevices = (
const domainName = domain ? domainToName(hass.localize, domain) : undefined;
return {
id: device.id,
id: `${idPrefix}${device.id}`,
label: "",
primary:
deviceName ||

View File

@@ -102,6 +102,7 @@ export type EnergySolarForecasts = Record<string, EnergySolarForecast>;
export interface DeviceConsumptionEnergyPreference {
// This is an ever increasing value
stat_consumption: string;
stat_rate?: string;
name?: string;
included_in_stat?: string;
}
@@ -130,11 +131,17 @@ export interface FlowToGridSourceEnergyPreference {
number_energy_price: number | null;
}
export interface GridPowerSourceEnergyPreference {
// W meter
stat_rate: string;
}
export interface GridSourceTypeEnergyPreference {
type: "grid";
flow_from: FlowFromGridSourceEnergyPreference[];
flow_to: FlowToGridSourceEnergyPreference[];
power?: GridPowerSourceEnergyPreference[];
cost_adjustment_day: number;
}
@@ -143,6 +150,7 @@ export interface SolarSourceTypeEnergyPreference {
type: "solar";
stat_energy_from: string;
stat_rate?: string;
config_entry_solar_forecast: string[] | null;
}
@@ -150,6 +158,7 @@ export interface BatterySourceTypeEnergyPreference {
type: "battery";
stat_energy_from: string;
stat_energy_to: string;
stat_rate?: string;
}
export interface GasSourceTypeEnergyPreference {
type: "gas";
@@ -351,6 +360,35 @@ export const getReferencedStatisticIds = (
return statIDs;
};
export const getReferencedStatisticIdsPower = (
prefs: EnergyPreferences
): string[] => {
const statIDs: (string | undefined)[] = [];
for (const source of prefs.energy_sources) {
if (source.type === "gas" || source.type === "water") {
continue;
}
if (source.type === "solar") {
statIDs.push(source.stat_rate);
continue;
}
if (source.type === "battery") {
statIDs.push(source.stat_rate);
continue;
}
if (source.power) {
statIDs.push(...source.power.map((p) => p.stat_rate));
}
}
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
return statIDs.filter(Boolean) as string[];
};
export const enum CompareMode {
NONE = "",
PREVIOUS = "previous",
@@ -398,9 +436,10 @@ const getEnergyData = async (
"gas",
"device",
]);
const powerStatIds = getReferencedStatisticIdsPower(prefs);
const waterStatIds = getReferencedStatisticIds(prefs, info, ["water"]);
const allStatIDs = [...energyStatIds, ...waterStatIds];
const allStatIDs = [...energyStatIds, ...waterStatIds, ...powerStatIds];
const dayDifference = differenceInDays(end || new Date(), start);
const period =
@@ -411,6 +450,8 @@ const getEnergyData = async (
: dayDifference > 2
? "day"
: "hour";
const finePeriod =
dayDifference > 64 ? "day" : dayDifference > 8 ? "hour" : "5minute";
const statsMetadata: Record<string, StatisticsMetaData> = {};
const statsMetadataArray = allStatIDs.length
@@ -432,6 +473,9 @@ const getEnergyData = async (
? (gasUnit as (typeof VOLUME_UNITS)[number])
: undefined,
};
const powerUnits: StatisticsUnitConfiguration = {
power: "kW",
};
const waterUnit = getEnergyWaterUnit(hass, prefs, statsMetadata);
const waterUnits: StatisticsUnitConfiguration = {
volume: waterUnit,
@@ -442,6 +486,12 @@ const getEnergyData = async (
"change",
])
: {};
const _powerStats: Statistics | Promise<Statistics> = powerStatIds.length
? fetchStatistics(hass!, start, end, powerStatIds, finePeriod, powerUnits, [
"mean",
])
: {};
const _waterStats: Statistics | Promise<Statistics> = waterStatIds.length
? fetchStatistics(hass!, start, end, waterStatIds, period, waterUnits, [
"change",
@@ -548,6 +598,7 @@ const getEnergyData = async (
const [
energyStats,
powerStats,
waterStats,
energyStatsCompare,
waterStatsCompare,
@@ -555,13 +606,14 @@ const getEnergyData = async (
fossilEnergyConsumptionCompare,
] = await Promise.all([
_energyStats,
_powerStats,
_waterStats,
_energyStatsCompare,
_waterStatsCompare,
_fossilEnergyConsumption,
_fossilEnergyConsumptionCompare,
]);
const stats = { ...energyStats, ...waterStats };
const stats = { ...energyStats, ...waterStats, ...powerStats };
if (compare) {
statsCompare = { ...energyStatsCompare, ...waterStatsCompare };
}

View File

@@ -344,7 +344,8 @@ export const getEntities = (
includeUnitOfMeasurement?: string[],
includeEntities?: string[],
excludeEntities?: string[],
value?: string
value?: string,
idPrefix = ""
): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = [];
@@ -395,10 +396,9 @@ export const getEntities = (
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
return {
id: entityId,
id: `${idPrefix}${entityId}`,
primary: primary,
secondary: secondary,
domain_name: domainName,
@@ -411,7 +411,6 @@ export const getEntities = (
friendlyName,
entityId,
].filter(Boolean) as string[],
a11y_label: a11yLabel,
stateObj: stateObj,
};
});

View File

@@ -51,6 +51,15 @@ export const deleteFloorRegistryEntry = (
floor_id: floorId,
});
export const reorderFloorRegistryEntries = (
hass: HomeAssistant,
floorIds: string[]
) =>
hass.callWS({
type: "config/floor_registry/reorder",
floor_ids: floorIds,
});
export const getFloorAreaLookup = (
areas: AreaRegistryEntry[]
): FloorAreaLookup => {

View File

@@ -3,6 +3,7 @@ import type { Connection } from "home-assistant-js-websocket";
export interface CoreFrontendUserData {
showAdvanced?: boolean;
showEntityIdPicker?: boolean;
defaultPanel?: string;
}
export interface SidebarFrontendUserData {
@@ -10,15 +11,24 @@ export interface SidebarFrontendUserData {
hiddenPanels: string[];
}
export interface CoreFrontendSystemData {
defaultPanel?: string;
}
declare global {
interface FrontendUserData {
core: CoreFrontendUserData;
sidebar: SidebarFrontendUserData;
}
interface FrontendSystemData {
core: CoreFrontendSystemData;
}
}
export type ValidUserDataKey = keyof FrontendUserData;
export type ValidSystemDataKey = keyof FrontendSystemData;
export const fetchFrontendUserData = async <
UserDataKey extends ValidUserDataKey,
>(
@@ -59,3 +69,46 @@ export const subscribeFrontendUserData = <UserDataKey extends ValidUserDataKey>(
key: userDataKey,
}
);
export const fetchFrontendSystemData = async <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey
): Promise<FrontendSystemData[SystemDataKey] | null> => {
const result = await conn.sendMessagePromise<{
value: FrontendSystemData[SystemDataKey] | null;
}>({
type: "frontend/get_system_data",
key,
});
return result.value;
};
export const saveFrontendSystemData = async <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey,
value: FrontendSystemData[SystemDataKey]
): Promise<void> =>
conn.sendMessagePromise<undefined>({
type: "frontend/set_system_data",
key,
value,
});
export const subscribeFrontendSystemData = <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
systemDataKey: SystemDataKey,
onChange: (data: { value: FrontendSystemData[SystemDataKey] | null }) => void
) =>
conn.subscribeMessage<{ value: FrontendSystemData[SystemDataKey] | null }>(
onChange,
{
type: "frontend/subscribe_system_data",
key: systemDataKey,
}
);

View File

@@ -59,6 +59,7 @@ import type {
} from "./entity_registry";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import { getTriggerDomain, getTriggerObjectId } from "./trigger";
/** Icon to use when no icon specified for service. */
export const DEFAULT_SERVICE_ICON = mdiRoomService;
@@ -133,14 +134,19 @@ const resources: {
all?: Promise<Record<string, ServiceIcons>>;
domains: Record<string, ServiceIcons | Promise<ServiceIcons>>;
};
triggers: {
all?: Promise<Record<string, TriggerIcons>>;
domains: Record<string, TriggerIcons | Promise<TriggerIcons>>;
};
} = {
entity: {},
entity_component: {},
services: { domains: {} },
triggers: { domains: {} },
};
interface IconResources<
T extends ComponentIcons | PlatformIcons | ServiceIcons,
T extends ComponentIcons | PlatformIcons | ServiceIcons | TriggerIcons,
> {
resources: Record<string, T>;
}
@@ -184,12 +190,22 @@ type ServiceIcons = Record<
{ service: string; sections?: Record<string, string> }
>;
export type IconCategory = "entity" | "entity_component" | "services";
type TriggerIcons = Record<
string,
{ trigger: string; sections?: Record<string, string> }
>;
export type IconCategory =
| "entity"
| "entity_component"
| "services"
| "triggers";
interface CategoryType {
entity: PlatformIcons;
entity_component: ComponentIcons;
services: ServiceIcons;
triggers: TriggerIcons;
}
export const getHassIcons = async <T extends IconCategory>(
@@ -258,42 +274,59 @@ export const getComponentIcons = async (
return resources.entity_component.resources.then((res) => res[domain]);
};
export const getServiceIcons = async (
export const getCategoryIcons = async <
T extends Exclude<IconCategory, "entity" | "entity_component">,
>(
hass: HomeAssistant,
category: T,
domain?: string,
force = false
): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> => {
): Promise<CategoryType[T] | Record<string, CategoryType[T]> | undefined> => {
if (!domain) {
if (!force && resources.services.all) {
return resources.services.all;
if (!force && resources[category].all) {
return resources[category].all as Promise<
Record<string, CategoryType[T]>
>;
}
resources.services.all = getHassIcons(hass, "services", domain).then(
(res) => {
resources.services.domains = res.resources;
return res?.resources;
}
);
return resources.services.all;
resources[category].all = getHassIcons(hass, category).then((res) => {
resources[category].domains = res.resources as any;
return res?.resources as Record<string, CategoryType[T]>;
}) as any;
return resources[category].all as Promise<Record<string, CategoryType[T]>>;
}
if (!force && domain in resources.services.domains) {
return resources.services.domains[domain];
if (!force && domain in resources[category].domains) {
return resources[category].domains[domain] as Promise<CategoryType[T]>;
}
if (resources.services.all && !force) {
await resources.services.all;
if (domain in resources.services.domains) {
return resources.services.domains[domain];
if (resources[category].all && !force) {
await resources[category].all;
if (domain in resources[category].domains) {
return resources[category].domains[domain] as Promise<CategoryType[T]>;
}
}
if (!isComponentLoaded(hass, domain)) {
return undefined;
}
const result = getHassIcons(hass, "services", domain);
resources.services.domains[domain] = result.then(
const result = getHassIcons(hass, category, domain);
resources[category].domains[domain] = result.then(
(res) => res?.resources[domain]
);
return resources.services.domains[domain];
) as any;
return resources[category].domains[domain] as Promise<CategoryType[T]>;
};
export const getServiceIcons = async (
hass: HomeAssistant,
domain?: string,
force = false
): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> =>
getCategoryIcons(hass, "services", domain, force);
export const getTriggerIcons = async (
hass: HomeAssistant,
domain?: string,
force = false
): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> =>
getCategoryIcons(hass, "triggers", domain, force);
// Cache for sorted range keys
const sortedRangeCache = new WeakMap<Record<string, string>, number[]>();
@@ -473,6 +506,26 @@ export const attributeIcon = async (
return icon;
};
export const triggerIcon = async (
hass: HomeAssistant,
trigger: string
): Promise<string | undefined> => {
let icon: string | undefined;
const domain = getTriggerDomain(trigger);
const triggerName = getTriggerObjectId(trigger);
const triggerIcons = await getTriggerIcons(hass, domain);
if (triggerIcons) {
const trgrIcon = triggerIcons[triggerName] as TriggerIcons[string];
icon = trgrIcon?.trigger;
}
if (!icon) {
icon = await domainIcon(hass, domain);
}
return icon;
};
export const serviceIcon = async (
hass: HomeAssistant,
service: string

View File

@@ -108,7 +108,8 @@ export const getLabels = (
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeLabels?: string[]
excludeLabels?: string[],
idPrefix = ""
): PickerComboBoxItem[] => {
if (!labels || labels.length === 0) {
return [];
@@ -262,7 +263,7 @@ export const getLabels = (
}
const items = outputLabels.map<PickerComboBoxItem>((label) => ({
id: label.label_id,
id: `${idPrefix}${label.label_id}`,
primary: label.name,
secondary: label.description ?? "",
icon: label.icon || undefined,

View File

@@ -1,26 +1,24 @@
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant, PanelInfo } from "../types";
/** Panel to show when no panel is picked. */
export const DEFAULT_PANEL = "lovelace";
export const getStorageDefaultPanelUrlPath = (): string => {
export const getLegacyDefaultPanelUrlPath = (): string | null => {
const defaultPanel = window.localStorage.getItem("defaultPanel");
return defaultPanel ? JSON.parse(defaultPanel) : DEFAULT_PANEL;
return defaultPanel ? JSON.parse(defaultPanel) : null;
};
export const setDefaultPanel = (
element: HTMLElement,
urlPath: string
): void => {
fireEvent(element, "hass-default-panel", { defaultPanel: urlPath });
};
export const getDefaultPanelUrlPath = (hass: HomeAssistant): string =>
hass.userData?.defaultPanel ||
hass.systemData?.defaultPanel ||
getLegacyDefaultPanelUrlPath() ||
DEFAULT_PANEL;
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo =>
hass.panels[hass.defaultPanel]
? hass.panels[hass.defaultPanel]
: hass.panels[DEFAULT_PANEL];
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
const panel = getDefaultPanelUrlPath(hass);
return (panel ? hass.panels[panel] : undefined) ?? hass.panels[DEFAULT_PANEL];
};
export const getPanelNameTranslationKey = (panel: PanelInfo) => {
if (panel.url_path === "lovelace") {

View File

@@ -222,6 +222,7 @@ export interface StopAction extends BaseAction {
export interface SequenceAction extends BaseAction {
sequence: (ManualScriptConfig | Action)[];
metadata?: {};
}
export interface ParallelAction extends BaseAction {
@@ -479,6 +480,7 @@ export const migrateAutomationAction = (
}
if (typeof action === "object" && action !== null && "sequence" in action) {
delete (action as SequenceAction).metadata;
for (const sequenceAction of (action as SequenceAction).sequence) {
migrateAutomationAction(sequenceAction);
}

View File

@@ -28,6 +28,7 @@ export interface TodoItem {
status: TodoItemStatus | null;
description?: string | null;
due?: string | null;
completed?: string | null;
}
export const enum TodoListEntityFeature {

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