Compare commits

..

23 Commits

Author SHA1 Message Date
Jan Layola
98c4e34a23 Sort installed addons by name in the ha-config-logs component (#27056) 2025-09-15 17:01:54 +02:00
karwosts
3d005c8316 Manual entry mode for media selector (#26753) 2025-09-15 16:48:03 +02:00
Paul Bottein
af31b5add3 Add formatEntityName helper on hass object. (#26057) 2025-09-15 14:18:52 +00:00
Aidan Timson
9d02a1d391 Fix calendar toggle group wrapping (#27049) 2025-09-15 14:12:37 +00:00
Petar Petrov
98e6f32fe8 Improve device section organization in energy Sankey card (#26978) 2025-09-15 15:57:52 +02:00
Aidan Timson
2726c6a849 Fix calendar toggle group button sizes (#27050)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-09-15 13:31:49 +00:00
Aidan Timson
c09ec54c76 Safe area: containers and panels (#26971)
This commit squashes the following development history:

1e78af3aa - Restore, moved to #26969
d672d9f44 - Restore
53ee5fbbc - Restore
16b4eb98e - Restore
8d8b13f50 - Restore
62e16619d - Apply changes from #27003
f5ee79a47 - Fix
60678689c - Fix
766ed6a25 - Fix
f76bd4f7e - Fix fabs
1879fd0d9 - Fix
ea3ee6de5 - Add safe areas to ha-hanel-custom
aa3384b9a - Add missing
c9a7f76dc - Fix
78351fd1f - Fix
59789d379 - Fix
1c7aabd34 - Remove
eaf1373cf - Fix
8481a93d7 - Fix
fe7df1f2f - Remove
69f244ff3 - Restore
2eb936b64 - Adjust
b09350637 - Fix
c0504bb7e - Clean
b0773d73e - Fix
4caa4a43b - make sure narrow is passed
8885f6bf6 - Add safe areas to 2 pane fixed
62df70f63 - Clean
a87e68d87 - FIx
5086be030 - Fix energy
ac3478e54 - Fix
0f28098a6 - Restore
b65ba3df9 - Restore
b0e1ea6db - Restore
7bb78d1c7 - Fix
26c95df71 - Update
7369c79d3 - Remove
b5f31dad6 - Fix
40cfc437d - Set top level padding instead of individual panels
83b49729f - Restore
25db15816 - Fix
8c9c39827 - Set top level app bar padding instead of individual panels
b7a1b27c9 - Remove
1e9368705 - Device
1482502f9 - Integration page
98dc1bf56 - Fix
1c3de1376 - Add
a08bee4d8 - Remove
0d462439b - Area subpage
4bfd60875 - Areas fix
b5cbcdaf7 - Fix
9fb272074 - Add safe areas to script editor
7c3bc9433 - Add safe areas to scene editor
1cf1b999a - Fix mobile for automation editors
4413bd4b7 - Add safe areas to automation editor
2e6953327 - Add safe areas to blueprint editor
989776dd1 - Add config section padding
6692b7ccf - Fix header row
22337b5e2 - Fix calendar
414e058cd - Fix pane
f09ae0e0c - Fix calendar
fb5a984ee - Fix pane
1daee18c8 - Todo fab
6f52cb42b - Todo content
9b317c583 - Media browser
0f8ca248d - Fix history panel
cd7843799 - Fix logbook
b8d47ecf3 - Fix
d15e9311d - Safe area: dashboard view container should only apply left safe area when in full view

Summary of changes:
- Add narrow property to top app bar components for conditional safe area padding
- Update safe area inset calculations to use fallback values (0px) for better compatibility
- Fix content height calculations to account for safe area insets
- Apply safe area padding conditionally based on narrow state
- Update FAB positioning to respect safe area insets
- Ensure proper spacing and layout on mobile devices with notches/dynamic islands
2025-09-15 15:54:04 +03:00
Norbert Rittel
9f045538a2 Update beta / stable channel descriptions (#27047) 2025-09-15 11:49:11 +02:00
Paul Bottein
c6c4f91b0e Use slot for tile card info (#27046) 2025-09-15 11:29:48 +03:00
karwosts
f71d8f4367 Fix incorrect logbook entity filters (#27037)
* Fix incorrect logbook filters

* Update src/panels/lovelace/editor/config-elements/hui-logbook-card-editor.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-09-15 08:06:03 +00:00
Wendelin
68c1a38231 Unit tests for common/entity/get_states (#27007) 2025-09-15 09:28:02 +02:00
dependabot[bot]
a9796e4216 Bump github/codeql-action from 3.30.0 to 3.30.3 (#27045)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.30.0 to 3.30.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](2d92b76c45...192325c861)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-15 08:38:26 +02:00
Aidan Timson
bf6eefb692 Migrate from date-fns-tz to @date-fns/tz (#26809)
* Add @date-fns/tz

* Update calc_date

* Refactor ha-date-range-picker

* Refactor calendar panel

* Refactor todo panel

* Remove date-fns-tz

* Cleanup

* Move util functions

* Fix comment

* Reuse

* Restore old check for rrulejs, update to new format
2025-09-15 07:38:08 +03:00
Paul Bottein
7ec3b08444 Add disabled option for cards and sections (#27026)
* Add hidden config option for cards and sections

* Rename to disabled
2025-09-15 07:23:39 +03:00
Norbert Rittel
f3355671d1 Capitalize "Core" and "Supervisor" as component names (#27039) 2025-09-14 17:50:44 +02:00
Simon Lamon
c0e240a3bf Revert SHA pinning for home-assistant/wheels (#27034) 2025-09-13 16:47:57 +02:00
renovate[bot]
00fd4753e4 Update dependency @rspack/core to v1.5.3 (#27032)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-13 09:54:56 +03:00
renovate[bot]
08ac873e3b Update dependency globals to v16.4.0 (#27031)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-13 09:54:37 +03:00
renovate[bot]
d12b8d1b1b Update dependency hls.js to v1.6.12 (#27028)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-12 19:23:15 +02:00
Simon Lamon
977207dde4 Pin SHA for all github actions (#26958) 2025-09-12 19:17:44 +02:00
Norbert Rittel
87a5f1a315 Treat "Recorder" as a (capitalized) name that should not be translated (#27023) 2025-09-12 18:29:56 +02:00
renovate[bot]
acab2d5ead Update dependency ua-parser-js to v2.0.5 (#27024)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-12 16:42:16 +02:00
Paul Bottein
046fc00f73 Add home assistant bottom sheet (#26948)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-09-12 15:33:30 +02:00
104 changed files with 2360 additions and 1582 deletions

View File

@@ -21,12 +21,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: dev
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -56,12 +56,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: master
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -24,9 +24,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -37,7 +37,7 @@ jobs:
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache
uses: actions/cache@v4.2.4
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
node_modules/.cache/prettier
@@ -58,9 +58,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -76,9 +76,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -89,7 +89,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: frontend-bundle-stats
path: build/stats/*.json
@@ -100,9 +100,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -113,7 +113,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: supervisor-bundle-stats
path: build/stats/*.json

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.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@v3
uses: github/codeql-action/autobuild@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.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@v3
uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3

View File

@@ -22,12 +22,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: dev
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -57,12 +57,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: master
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -16,10 +16,10 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -21,10 +21,10 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply labels
uses: actions/labeler@v6.0.1
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
with:
sync-labels: true

View File

@@ -9,7 +9,7 @@ jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5.0.1
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with:
github-token: ${{ github.token }}
process-only: "issues, prs"

View File

@@ -20,15 +20,15 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v6
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -57,14 +57,14 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: wheels
path: dist/home_assistant_frontend*.whl
if-no-files-found: error
- name: Upload translations
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: translations
path: translations.tar.gz

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@v3.0.1
uses: relative-ci/agent-action@1707825cbfcc7452b2913d273414705415ae64d4 # v3.0.1
with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
token: ${{ github.token }}

View File

@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6.1.0
- uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # v6.1.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -23,10 +23,10 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v6
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
@@ -34,7 +34,7 @@ jobs:
uses: home-assistant/actions/helpers/verify-version@master
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -55,7 +55,7 @@ jobs:
script/release
- name: Upload release assets
uses: softprops/action-gh-release@v2.3.3
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
with:
files: |
dist/*.whl
@@ -73,6 +73,7 @@ jobs:
version=$(echo "${{ github.ref }}" | awk -F"/" '{print $NF}' )
echo "home-assistant-frontend==$version" > ./requirements.txt
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/wheels@2025.07.0
with:
@@ -90,9 +91,9 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -107,7 +108,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@v2.3.3
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
with:
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
@@ -119,9 +120,9 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@v5.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -136,6 +137,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@v2.3.3
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
with:
files: hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz

View File

@@ -12,7 +12,7 @@ jobs:
if: github.event.issue.type.name == 'Task'
steps:
- name: Check if user is authorized
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const issueAuthor = context.payload.issue.user.login;

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 90 days stale policy
uses: actions/stale@v10.0.0
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Upload Translations
run: |

View File

@@ -35,6 +35,7 @@
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.38.2",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.0",
"@formatjs/intl-displaynames": "6.8.11",
@@ -102,7 +103,6 @@
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.1.0",
"date-fns-tz": "3.2.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
@@ -111,7 +111,7 @@
"fuse.js": "7.1.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.11",
"hls.js": "1.6.12",
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.16",
@@ -135,7 +135,7 @@
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.4",
"ua-parser-js": "2.0.5",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0",
@@ -158,7 +158,7 @@
"@octokit/plugin-retry": "8.0.1",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.2.3",
"@rspack/core": "1.5.2",
"@rspack/core": "1.5.3",
"@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.24",
@@ -231,7 +231,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.1",
"@fullcalendar/daygrid": "6.1.19",
"globals": "16.3.0",
"globals": "16.4.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"
},

View File

@@ -11,7 +11,7 @@ import {
differenceInDays,
addDays,
} from "date-fns";
import { toZonedTime, fromZonedTime } from "date-fns-tz";
import { TZDate } from "@date-fns/tz";
import type { HassConfig } from "home-assistant-js-websocket";
import type { FrontendLocaleData } from "../../data/translation";
import { TimeZone } from "../../data/translation";
@@ -22,12 +22,13 @@ const calcZonedDate = (
fn: (date: Date, options?: any) => Date | number | boolean,
options?
) => {
const inputZoned = toZonedTime(date, tz);
const fnZoned = fn(inputZoned, options);
if (fnZoned instanceof Date) {
return fromZonedTime(fnZoned, tz) as Date;
const tzDate = new TZDate(date, tz);
const fnResult = fn(tzDate, options);
if (fnResult instanceof Date) {
// Convert back to regular Date in the specified timezone
return new Date(fnResult.getTime());
}
return fnZoned;
return fnResult;
};
export const calcDate = (
@@ -65,7 +66,7 @@ export const calcDateDifferenceProperty = (
locale,
config,
locale.time_zone === TimeZone.server
? toZonedTime(startDate, config.time_zone)
? new TZDate(startDate, config.time_zone)
: startDate
);
@@ -144,3 +145,36 @@ export const shiftDateRange = (
}
return { start, end };
};
/**
* @description Parses a date in browser display timezone
* @param date - The date to parse
* @param timezone - The timezone to parse the date in
* @returns The parsed date as a Date object
*/
export const parseDate = (date: string, timezone: string): Date => {
const tzDate = new TZDate(date, timezone);
return new Date(tzDate.getTime());
};
/**
* @description Formats a date in browser display timezone
* @param date - The date to format
* @param timezone - The timezone to format the date in
* @returns The formatted date in YYYY-MM-DD format
*/
export const formatDate = (date: Date, timezone: string): string => {
const tzDate = new TZDate(date, timezone);
return tzDate.toISOString().split("T")[0];
};
/**
* @description Formats a time in browser display timezone
* @param date - The date to format
* @param timezone - The timezone to format the time in
* @returns The formatted time in HH:mm:ss format
*/
export const formatTime = (date: Date, timezone: string): string => {
const tzDate = new TZDate(date, timezone);
return tzDate.toISOString().split("T")[1].split(".")[0];
};

View File

@@ -10,9 +10,10 @@ import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name";
export const computeEntityName = (
stateObj: HassEntity,
hass: HomeAssistant
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): string | undefined => {
const entry = hass.entities[stateObj.entity_id] as
const entry = entities[stateObj.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
@@ -20,12 +21,13 @@ export const computeEntityName = (
// Fall back to state name if not in the entity registry (friendly name)
return computeStateName(stateObj);
}
return computeEntityEntryName(entry, hass);
return computeEntityEntryName(entry, devices);
};
export const computeEntityEntryName = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
hass: HomeAssistant
devices: HomeAssistant["devices"],
fallbackStateObj?: HassEntity
): string | undefined => {
const name =
entry.name ||
@@ -33,15 +35,14 @@ export const computeEntityEntryName = (
? String(entry.original_name)
: undefined);
const device = entry.device_id ? hass.devices[entry.device_id] : undefined;
const device = entry.device_id ? devices[entry.device_id] : undefined;
if (!device) {
if (name) {
return name;
}
const stateObj = hass.states[entry.entity_id] as HassEntity | undefined;
if (stateObj) {
return computeStateName(stateObj);
if (fallbackStateObj) {
return computeStateName(fallbackStateObj);
}
return undefined;
}

View File

@@ -18,9 +18,12 @@ interface EntityContext {
export const getEntityContext = (
stateObj: HassEntity,
hass: HomeAssistant
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): EntityContext => {
const entry = hass.entities[stateObj.entity_id] as
const entry = entities[stateObj.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
@@ -32,7 +35,7 @@ export const getEntityContext = (
floor: null,
};
}
return getEntityEntryContext(entry, hass);
return getEntityEntryContext(entry, entities, devices, areas, floors);
};
export const getEntityEntryContext = (
@@ -40,15 +43,18 @@ export const getEntityEntryContext = (
| EntityRegistryDisplayEntry
| EntityRegistryEntry
| ExtEntityRegistryEntry,
hass: HomeAssistant
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): EntityContext => {
const entity = hass.entities[entry.entity_id];
const entity = entities[entry.entity_id];
const deviceId = entry?.device_id;
const device = deviceId ? hass.devices[deviceId] : undefined;
const device = deviceId ? devices[deviceId] : undefined;
const areaId = entry?.area_id || device?.area_id;
const area = areaId ? hass.areas[areaId] : undefined;
const area = areaId ? areas[areaId] : undefined;
const floorId = area?.floor_id;
const floor = floorId ? hass.floors[floorId] : undefined;
const floor = floorId ? floors[floorId] : undefined;
return {
entity: entity,

View File

@@ -60,7 +60,13 @@ export const generateEntityFilter = (
}
}
const { area, floor, device, entity } = getEntityContext(stateObj, hass);
const { area, floor, device, entity } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
if (entity && entity.hidden) {
return false;

View File

@@ -2,6 +2,12 @@ import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistant } from "../../types";
import type { LocalizeFunc } from "./localize";
import { computeEntityName } from "../entity/compute_entity_name";
import { computeDeviceName } from "../entity/compute_device_name";
import { getEntityContext } from "../entity/context/get_entity_context";
import { computeAreaName } from "../entity/compute_area_name";
import { computeFloorName } from "../entity/compute_floor_name";
import { ensureArray } from "../array/ensure-array";
export type FormatEntityStateFunc = (
stateObj: HassEntity,
@@ -17,16 +23,28 @@ export type FormatEntityAttributeNameFunc = (
attribute: string
) => string;
export type EntityNameType = "entity" | "device" | "area" | "floor";
export type FormatEntityNameFunc = (
stateObj: HassEntity,
type: EntityNameType | EntityNameType[],
separator?: string
) => string;
export const computeFormatFunctions = async (
localize: LocalizeFunc,
locale: FrontendLocaleData,
config: HassConfig,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
sensorNumericDeviceClasses: string[]
): Promise<{
formatEntityState: FormatEntityStateFunc;
formatEntityAttributeValue: FormatEntityAttributeValueFunc;
formatEntityAttributeName: FormatEntityAttributeNameFunc;
formatEntityName: FormatEntityNameFunc;
}> => {
const { computeStateDisplay } = await import(
"../entity/compute_state_display"
@@ -57,5 +75,45 @@ export const computeFormatFunctions = async (
),
formatEntityAttributeName: (stateObj, attribute) =>
computeAttributeNameDisplay(localize, stateObj, entities, attribute),
formatEntityName: (stateObj, type, separator = " ") => {
const types = ensureArray(type);
const namesList: (string | undefined)[] = [];
const { device, area, floor } = getEntityContext(
stateObj,
entities,
devices,
areas,
floors
);
for (const t of types) {
switch (t) {
case "entity": {
namesList.push(computeEntityName(stateObj, entities, devices));
break;
}
case "device": {
if (device) {
namesList.push(computeDeviceName(device));
}
break;
}
case "area": {
if (area) {
namesList.push(computeAreaName(area));
}
break;
}
case "floor": {
if (floor) {
namesList.push(computeFloorName(floor));
}
break;
}
}
}
return namesList.filter((name) => name !== undefined).join(separator);
},
};
};

View File

@@ -5,12 +5,8 @@ import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
@@ -148,11 +144,9 @@ export class HaEntityPicker extends LitElement {
`;
}
const { area, device } = getEntityContext(stateObj, this.hass);
const entityName = computeEntityName(stateObj, this.hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const entityName = this.hass.formatEntityName(stateObj, "entity");
const deviceName = this.hass.formatEntityName(stateObj, "device");
const areaName = this.hass.formatEntityName(stateObj, "area");
const isRTL = computeRTL(this.hass);
@@ -311,12 +305,10 @@ export class HaEntityPicker extends LitElement {
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass!.states[entityId];
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const entityName = this.hass.formatEntityName(stateObj, "entity");
const deviceName = this.hass.formatEntityName(stateObj, "device");
const areaName = this.hass.formatEntityName(stateObj, "area");
const domainName = domainToName(
this.hass.localize,

View File

@@ -6,11 +6,7 @@ import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import {
@@ -259,12 +255,10 @@ export class HaStatisticPicker extends LitElement {
}
const id = meta.statistic_id;
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const entityName = hass.formatEntityName(stateObj, "entity");
const deviceName = hass.formatEntityName(stateObj, "device");
const areaName = hass.formatEntityName(stateObj, "area");
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
@@ -337,11 +331,9 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const { area, device } = getEntityContext(stateObj, this.hass);
const entityName = computeEntityName(stateObj, this.hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const entityName = this.hass.formatEntityName(stateObj, "entity");
const deviceName = this.hass.formatEntityName(stateObj, "device");
const areaName = this.hass.formatEntityName(stateObj, "area");
const isRTL = computeRTL(this.hass);

View File

@@ -1,262 +1,62 @@
import { css, html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { css, html, LitElement, type PropertyValues } from "lit";
import "@home-assistant/webawesome/dist/components/drawer/drawer";
import { customElement, property, state } from "lit/decorators";
const ANIMATION_DURATION_MS = 300;
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
/**
* A bottom sheet component that slides up from the bottom of the screen.
*
* The bottom sheet provides a draggable interface that allows users to resize
* the sheet by dragging the handle at the top. It supports both mouse and touch
* interactions and automatically closes when dragged below a 20% of screen height.
*
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
*
* @cssprop --ha-bottom-sheet-border-width - Border width for the sheet
* @cssprop --ha-bottom-sheet-border-style - Border style for the sheet
* @cssprop --ha-bottom-sheet-border-color - Border color for the sheet
*/
@customElement("ha-bottom-sheet")
export class HaBottomSheet extends LitElement {
@query("dialog") private _dialog!: HTMLDialogElement;
@property({ type: Boolean }) public open = false;
private _dragging = false;
@state() private _drawerOpen = false;
private _dragStartY = 0;
private _handleAfterHide() {
this.open = false;
const ev = new Event("closed", {
bubbles: true,
composed: true,
});
this.dispatchEvent(ev);
}
private _initialSize = 0;
@state() private _dialogMaxViewpointHeight = 70;
@state() private _dialogMinViewpointHeight = 55;
@state() private _dialogViewportHeight?: number;
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has("open")) {
this._drawerOpen = this.open;
}
}
render() {
return html`<dialog
open
@transitionend=${this._handleTransitionEnd}
style=${styleMap({
height: this._dialogViewportHeight
? `${this._dialogViewportHeight}vh`
: "auto",
maxHeight: `${this._dialogMaxViewpointHeight}vh`,
minHeight: `${this._dialogMinViewpointHeight}vh`,
})}
>
<div class="handle-wrapper">
<div
@mousedown=${this._handleMouseDown}
@touchstart=${this._handleTouchStart}
class="handle"
></div>
</div>
<slot></slot>
</dialog>`;
}
protected firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this._openSheet();
}
private _openSheet() {
requestAnimationFrame(() => {
// trigger opening animation
this._dialog.classList.add("show");
});
}
public closeSheet() {
requestAnimationFrame(() => {
this._dialog.classList.remove("show");
});
}
private _handleTransitionEnd() {
if (this._dialog.classList.contains("show")) {
// after show animation is done
// - set the height to the natural height, to prevent content shift when switch content
// - set max height to 90vh, so it opens at max 70vh but can be resized to 90vh
this._dialogViewportHeight =
(this._dialog.offsetHeight / window.innerHeight) * 100;
this._dialogMaxViewpointHeight = 90;
this._dialogMinViewpointHeight = 20;
} else {
// after close animation is done close dialog element and fire closed event
this._dialog.close();
fireEvent(this, "bottom-sheet-closed");
}
}
connectedCallback() {
super.connectedCallback();
// register event listeners for drag handling
document.addEventListener("mousemove", this._handleMouseMove);
document.addEventListener("mouseup", this._handleMouseUp);
document.addEventListener("touchmove", this._handleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
}
disconnectedCallback() {
super.disconnectedCallback();
// unregister event listeners for drag handling
document.removeEventListener("mousemove", this._handleMouseMove);
document.removeEventListener("mouseup", this._handleMouseUp);
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
}
private _handleMouseDown = (ev: MouseEvent) => {
this._startDrag(ev.clientY);
};
private _handleTouchStart = (ev: TouchEvent) => {
// Prevent the browser from interpreting this as a scroll/PTR gesture.
ev.preventDefault();
this._startDrag(ev.touches[0].clientY);
};
private _startDrag(clientY: number) {
this._dragging = true;
this._dragStartY = clientY;
this._initialSize = (this._dialog.offsetHeight / window.innerHeight) * 100;
document.body.style.setProperty("cursor", "grabbing");
}
private _handleMouseMove = (ev: MouseEvent) => {
if (!this._dragging) {
return;
}
this._updateSize(ev.clientY);
};
private _handleTouchMove = (ev: TouchEvent) => {
if (!this._dragging) {
return;
}
ev.preventDefault(); // Prevent scrolling
this._updateSize(ev.touches[0].clientY);
};
private _updateSize(clientY: number) {
const deltaY = this._dragStartY - clientY;
const viewportHeight = window.innerHeight;
const deltaVh = (deltaY / viewportHeight) * 100;
// Calculate new size and clamp between 10vh and 90vh
let newSize = this._initialSize + deltaVh;
newSize = Math.max(10, Math.min(90, newSize));
// on drag down and below 20vh
if (newSize < 20 && deltaY < 0) {
this._endDrag();
this.closeSheet();
return;
}
this._dialogViewportHeight = newSize;
}
private _handleMouseUp = () => {
this._endDrag();
};
private _handleTouchEnd = () => {
this._endDrag();
};
private _endDrag() {
if (!this._dragging) {
return;
}
this._dragging = false;
document.body.style.removeProperty("cursor");
return html`
<wa-drawer
placement="bottom"
.open=${this._drawerOpen}
@wa-after-hide=${this._handleAfterHide}
without-header
>
<slot></slot>
</wa-drawer>
`;
}
static styles = css`
.handle-wrapper {
position: absolute;
top: 0;
width: 100%;
padding-bottom: 2px;
display: flex;
justify-content: center;
align-items: center;
cursor: grab;
touch-action: none;
}
.handle-wrapper .handle {
height: 20px;
width: 200px;
display: flex;
justify-content: center;
align-items: center;
z-index: 7;
padding-bottom: 76px;
}
.handle-wrapper .handle::after {
content: "";
border-radius: 8px;
height: 4px;
background: var(--divider-color, #e0e0e0);
width: 80px;
}
.handle-wrapper .handle:active::after {
cursor: grabbing;
}
dialog {
height: auto;
max-height: 70vh;
min-height: 30vh;
background-color: var(
wa-drawer {
--wa-color-surface-raised: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
display: flex;
flex-direction: column;
top: 0;
inset-inline-start: 0;
position: fixed;
width: calc(100% - 4px);
max-width: 100%;
border: none;
box-shadow: var(--wa-shadow-l);
padding: 0;
margin: 0;
top: auto;
inset-inline-end: auto;
bottom: 0;
inset-inline-start: 0;
box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
border-top-left-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-2xl)
);
border-top-right-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-2xl)
);
transform: translateY(100%);
transition: transform ${ANIMATION_DURATION_MS}ms ease;
border-top-width: var(--ha-bottom-sheet-border-width);
border-right-width: var(--ha-bottom-sheet-border-width);
border-left-width: var(--ha-bottom-sheet-border-width);
border-bottom-width: 0;
border-style: var(--ha-bottom-sheet-border-style);
border-color: var(--ha-bottom-sheet-border-color);
--spacing: 0;
--size: auto;
--show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
--hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
}
dialog.show {
transform: translateY(0);
wa-drawer::part(dialog) {
border-top-left-radius: var(--ha-border-radius-lg);
border-top-right-radius: var(--ha-border-radius-lg);
max-height: 90vh;
}
wa-drawer::part(body) {
padding-bottom: var(--safe-area-inset-bottom);
}
`;
}
@@ -265,8 +65,4 @@ declare global {
interface HTMLElementTagNameMap {
"ha-bottom-sheet": HaBottomSheet;
}
interface HASSDomEvents {
"bottom-sheet-closed": undefined;
}
}

View File

@@ -28,6 +28,9 @@ export class HaButtonToggleGroup extends LitElement {
@property({ reflect: true }) size: "small" | "medium" = "medium";
@property({ type: Boolean, reflect: true, attribute: "no-wrap" })
public nowrap = false;
@property() public variant:
| "brand"
| "neutral"
@@ -71,6 +74,10 @@ export class HaButtonToggleGroup extends LitElement {
:host {
--mdc-icon-size: var(--button-toggle-icon-size, 20px);
}
:host([no-wrap]) wa-button-group::part(base) {
flex-wrap: nowrap;
}
`;
}

View File

@@ -330,7 +330,13 @@ export class HaCodeEditor extends ReactiveElement {
private _renderInfo = (completion: Completion): CompletionInfo => {
const key = completion.label;
const context = getEntityContext(this.hass!.states[key], this.hass!);
const context = getEntityContext(
this.hass!.states[key],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const completionInfo = document.createElement("div");
completionInfo.classList.add("completion-info");

View File

@@ -2,7 +2,7 @@ import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import { mdiCalendar } from "@mdi/js";
import { isThisYear } from "date-fns";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import { TZDate } from "@date-fns/tz";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -275,8 +275,8 @@ export class HaDateRangePicker extends LitElement {
}
if (this.hass.locale.time_zone === TimeZone.server) {
start = fromZonedTime(start, this.hass.config.time_zone);
end = fromZonedTime(end, this.hass.config.time_zone);
start = new Date(new TZDate(start, this.hass.config.time_zone).getTime());
end = new Date(new TZDate(end, this.hass.config.time_zone).getTime());
}
if (
@@ -290,7 +290,7 @@ export class HaDateRangePicker extends LitElement {
private _formatDate(date: Date): string {
if (this.hass.locale.time_zone === TimeZone.server) {
return toZonedTime(date, this.hass.config.time_zone).toISOString();
return new TZDate(date, this.hass.config.time_zone).toISOString();
}
return date.toISOString();
}

View File

@@ -0,0 +1,271 @@
import { css, html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
/**
* A bottom sheet component that slides up from the bottom of the screen.
*
* The bottom sheet provides a draggable interface that allows users to resize
* the sheet by dragging the handle at the top. It supports both mouse and touch
* interactions and automatically closes when dragged below a 20% of screen height.
*
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
*
* @cssprop --ha-bottom-sheet-border-width - Border width for the sheet
* @cssprop --ha-bottom-sheet-border-style - Border style for the sheet
* @cssprop --ha-bottom-sheet-border-color - Border color for the sheet
*/
@customElement("ha-resizable-bottom-sheet")
export class HaResizableBottomSheet extends LitElement {
@query("dialog") private _dialog!: HTMLDialogElement;
private _dragging = false;
private _dragStartY = 0;
private _initialSize = 0;
@state() private _dialogMaxViewpointHeight = 70;
@state() private _dialogMinViewpointHeight = 55;
@state() private _dialogViewportHeight?: number;
render() {
return html`<dialog
open
@transitionend=${this._handleTransitionEnd}
style=${styleMap({
height: this._dialogViewportHeight
? `${this._dialogViewportHeight}vh`
: "auto",
maxHeight: `${this._dialogMaxViewpointHeight}vh`,
minHeight: `${this._dialogMinViewpointHeight}vh`,
})}
>
<div class="handle-wrapper">
<div
@mousedown=${this._handleMouseDown}
@touchstart=${this._handleTouchStart}
class="handle"
></div>
</div>
<slot></slot>
</dialog>`;
}
protected firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this._openSheet();
}
private _openSheet() {
requestAnimationFrame(() => {
// trigger opening animation
this._dialog.classList.add("show");
});
}
public closeSheet() {
requestAnimationFrame(() => {
this._dialog.classList.remove("show");
});
}
private _handleTransitionEnd() {
if (this._dialog.classList.contains("show")) {
// after show animation is done
// - set the height to the natural height, to prevent content shift when switch content
// - set max height to 90vh, so it opens at max 70vh but can be resized to 90vh
this._dialogViewportHeight =
(this._dialog.offsetHeight / window.innerHeight) * 100;
this._dialogMaxViewpointHeight = 90;
this._dialogMinViewpointHeight = 20;
} else {
// after close animation is done close dialog element and fire closed event
this._dialog.close();
fireEvent(this, "bottom-sheet-closed");
}
}
connectedCallback() {
super.connectedCallback();
// register event listeners for drag handling
document.addEventListener("mousemove", this._handleMouseMove);
document.addEventListener("mouseup", this._handleMouseUp);
document.addEventListener("touchmove", this._handleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
}
disconnectedCallback() {
super.disconnectedCallback();
// unregister event listeners for drag handling
document.removeEventListener("mousemove", this._handleMouseMove);
document.removeEventListener("mouseup", this._handleMouseUp);
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
}
private _handleMouseDown = (ev: MouseEvent) => {
this._startDrag(ev.clientY);
};
private _handleTouchStart = (ev: TouchEvent) => {
// Prevent the browser from interpreting this as a scroll/PTR gesture.
ev.preventDefault();
this._startDrag(ev.touches[0].clientY);
};
private _startDrag(clientY: number) {
this._dragging = true;
this._dragStartY = clientY;
this._initialSize = (this._dialog.offsetHeight / window.innerHeight) * 100;
document.body.style.setProperty("cursor", "grabbing");
}
private _handleMouseMove = (ev: MouseEvent) => {
if (!this._dragging) {
return;
}
this._updateSize(ev.clientY);
};
private _handleTouchMove = (ev: TouchEvent) => {
if (!this._dragging) {
return;
}
ev.preventDefault(); // Prevent scrolling
this._updateSize(ev.touches[0].clientY);
};
private _updateSize(clientY: number) {
const deltaY = this._dragStartY - clientY;
const viewportHeight = window.innerHeight;
const deltaVh = (deltaY / viewportHeight) * 100;
// Calculate new size and clamp between 10vh and 90vh
let newSize = this._initialSize + deltaVh;
newSize = Math.max(10, Math.min(90, newSize));
// on drag down and below 20vh
if (newSize < 20 && deltaY < 0) {
this._endDrag();
this.closeSheet();
return;
}
this._dialogViewportHeight = newSize;
}
private _handleMouseUp = () => {
this._endDrag();
};
private _handleTouchEnd = () => {
this._endDrag();
};
private _endDrag() {
if (!this._dragging) {
return;
}
this._dragging = false;
document.body.style.removeProperty("cursor");
}
static styles = css`
.handle-wrapper {
position: absolute;
top: 0;
width: 100%;
padding-bottom: 2px;
display: flex;
justify-content: center;
align-items: center;
cursor: grab;
touch-action: none;
}
.handle-wrapper .handle {
height: 20px;
width: 200px;
display: flex;
justify-content: center;
align-items: center;
z-index: 7;
padding-bottom: 76px;
}
.handle-wrapper .handle::after {
content: "";
border-radius: 8px;
height: 4px;
background: var(--divider-color, #e0e0e0);
width: 80px;
}
.handle-wrapper .handle:active::after {
cursor: grabbing;
}
dialog {
height: auto;
max-height: 70vh;
min-height: 30vh;
background-color: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
display: flex;
flex-direction: column;
top: 0;
inset-inline-start: 0;
position: fixed;
width: calc(100% - 4px);
max-width: 100%;
border: none;
box-shadow: var(--wa-shadow-l);
padding: 0;
margin: 0;
top: auto;
inset-inline-end: auto;
bottom: 0;
inset-inline-start: 0;
box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
border-top-left-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-2xl)
);
border-top-right-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-2xl)
);
transform: translateY(100%);
transition: transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease;
border-top-width: var(--ha-bottom-sheet-border-width);
border-right-width: var(--ha-bottom-sheet-border-width);
border-left-width: var(--ha-bottom-sheet-border-width);
border-bottom-width: 0;
border-style: var(--ha-bottom-sheet-border-style);
border-color: var(--ha-bottom-sheet-border-color);
}
dialog.show {
transform: translateY(0);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-resizable-bottom-sheet": HaResizableBottomSheet;
}
interface HASSDomEvents {
"bottom-sheet-closed": undefined;
}
}

View File

@@ -246,6 +246,8 @@ export class HaMediaSelector extends LitElement {
entityId: this._getActiveEntityId(),
navigateIds: this.value?.metadata?.navigateIds,
accept: this.selector.media?.accept,
defaultId: this.value?.media_content_id,
defaultType: this.value?.media_content_type,
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
fireEvent(this, "value-changed", {
value: {

View File

@@ -1,10 +1,12 @@
import { TopAppBarFixedBase } from "@material/mwc-top-app-bar-fixed/mwc-top-app-bar-fixed-base";
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { customElement, property } from "lit/decorators";
@customElement("ha-top-app-bar-fixed")
export class HaTopAppBarFixed extends TopAppBarFixedBase {
@property({ type: Boolean, reflect: true }) public narrow = false;
static override styles = [
styles,
css`
@@ -13,12 +15,17 @@ export class HaTopAppBarFixed extends TopAppBarFixedBase {
}
.mdc-top-app-bar__row {
height: var(--header-height);
padding-left: var(--safe-area-content-inset-left);
padding-right: var(--safe-area-content-inset-right);
border-bottom: var(--app-header-border-bottom);
}
.mdc-top-app-bar--fixed-adjust {
padding-top: calc(var(--safe-area-inset-top) + var(--header-height));
padding-top: calc(
var(--header-height, 0px) + var(--safe-area-inset-top, 0px)
);
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .mdc-top-app-bar--fixed-adjust {
padding-left: var(--safe-area-inset-left);
}
.mdc-top-app-bar {
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
@@ -28,9 +35,11 @@ export class HaTopAppBarFixed extends TopAppBarFixedBase {
var(--mdc-theme-primary)
);
padding-top: var(--safe-area-inset-top);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .mdc-top-app-bar {
padding-left: var(--safe-area-inset-left);
}
.mdc-top-app-bar__title {
font-size: var(--ha-font-size-xl);
padding-inline-start: 24px;

View File

@@ -32,6 +32,8 @@ export class TopAppBarBaseBase extends BaseElement {
protected _scrollTarget!: HTMLElement | Window;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "center-title", type: Boolean }) centerTitle = false;
@property({ type: Boolean, reflect: true }) prominent = false;
@@ -252,7 +254,14 @@ export class TopAppBarBaseBase extends BaseElement {
border-bottom: var(--app-header-border-bottom);
}
.mdc-top-app-bar--fixed-adjust {
padding-top: calc(var(--safe-area-inset-top) + var(--header-height));
padding-top: calc(
var(--header-height, 0px) + var(--safe-area-inset-top, 0px)
);
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .mdc-top-app-bar--fixed-adjust {
padding-left: var(--safe-area-inset-left);
}
.shadow-container {
position: absolute;
@@ -278,9 +287,11 @@ export class TopAppBarBaseBase extends BaseElement {
var(--mdc-theme-primary)
);
padding-top: var(--safe-area-inset-top);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .mdc-top-app-bar {
padding-left: var(--safe-area-inset-left);
}
.mdc-top-app-bar--pane.mdc-top-app-bar--fixed-scrolled {
box-shadow: none;
}
@@ -294,7 +305,12 @@ export class TopAppBarBaseBase extends BaseElement {
}
div.mdc-top-app-bar--pane {
display: flex;
height: calc(100vh - var(--header-height));
height: calc(
100vh - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
}
.pane {
border-right: 1px solid var(--divider-color);

View File

@@ -165,6 +165,8 @@ class DialogMediaPlayerBrowse extends LitElement {
.action=${this._action}
.preferredLayout=${this._preferredLayout}
.accept=${this._params.accept}
.defaultId=${this._params.defaultId}
.defaultType=${this._params.defaultType}
@close-dialog=${this.closeDialog}
@media-picked=${this._mediaPicked}
@media-browsed=${this._mediaBrowsed}

View File

@@ -0,0 +1,112 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import "../ha-card";
import "../ha-form/ha-form";
import type { SchemaUnion } from "../ha-form/types";
import type { MediaPlayerItemId } from "./ha-media-player-browse";
export interface ManualMediaPickedEvent {
item: MediaPlayerItemId;
}
@customElement("ha-browse-media-manual")
class BrowseMediaManual extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public item!: MediaPlayerItemId;
private _schema = memoizeOne(
() =>
[
{
name: "media_content_id",
required: true,
selector: {
text: {},
},
},
{
name: "media_content_type",
required: false,
selector: {
text: {},
},
},
] as const
);
protected render() {
return html`
<ha-card>
<div class="card-content">
<ha-form
.hass=${this.hass}
.schema=${this._schema()}
.data=${this.item}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
</div>
<div class="card-actions">
<ha-button @click=${this._mediaPicked}>
${this.hass.localize("ui.common.submit")}
</ha-button>
</div>
</ha-card>
`;
}
private _valueChanged(ev: CustomEvent) {
const value = { ...ev.detail.value };
this.item = value;
}
private _computeLabel = (
entry: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(`ui.components.selectors.media.${entry.name}`);
private _computeHelper = (
entry: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(`ui.components.selectors.media.${entry.name}_detail`);
private _mediaPicked() {
fireEvent(this, "manual-media-picked", {
item: {
media_content_id: this.item.media_content_id || "",
media_content_type: this.item.media_content_type || "",
},
});
}
static override styles = css`
:host {
margin: 16px auto;
padding: 0 8px;
display: flex;
flex-direction: column;
max-width: 448px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-browse-media-manual": BrowseMediaManual;
}
interface HASSDomEvents {
"manual-media-picked": ManualMediaPickedEvent;
}
}

View File

@@ -1,7 +1,7 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { grid } from "@lit-labs/virtualizer/layouts/grid";
import { mdiArrowUpRight, mdiPlay, mdiPlus } from "@mdi/js";
import { mdiArrowUpRight, mdiPlay, mdiPlus, mdiKeyboard } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -28,7 +28,11 @@ import {
BROWSER_PLAYER,
MediaClassBrowserSettings,
} from "../../data/media-player";
import { browseLocalMediaPlayer } from "../../data/media_source";
import {
browseLocalMediaPlayer,
isManualMediaSourceContentId,
MANUAL_MEDIA_SOURCE_PREFIX,
} from "../../data/media_source";
import { isTTSMediaSource } from "../../data/tts";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../resources/styles";
@@ -53,7 +57,9 @@ import "../ha-spinner";
import "../ha-svg-icon";
import "../ha-tooltip";
import "./ha-browse-media-tts";
import "./ha-browse-media-manual";
import type { TtsMediaPickedEvent } from "./ha-browse-media-tts";
import type { ManualMediaPickedEvent } from "./ha-browse-media-manual";
declare global {
interface HASSDomEvents {
@@ -74,6 +80,18 @@ export interface MediaPlayerItemId {
media_content_type: string | undefined;
}
const MANUAL_ITEM: MediaPlayerItem = {
can_expand: true,
can_play: false,
can_search: false,
children_media_class: "",
media_class: "app",
media_content_id: MANUAL_MEDIA_SOURCE_PREFIX,
media_content_type: "",
iconPath: mdiKeyboard,
title: "Manual entry",
};
@customElement("ha-media-player-browse")
export class HaMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -91,6 +109,10 @@ export class HaMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public accept?: string[];
@property({ attribute: false }) public defaultId?: string;
@property({ attribute: false }) public defaultType?: string;
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@property({ type: Boolean, reflect: true }) public narrow = false;
@@ -216,56 +238,69 @@ export class HaMediaPlayerBrowse extends LitElement {
}
}
// Fetch current
if (!currentProm) {
currentProm = this._fetchData(
this.entityId,
currentId.media_content_id,
currentId.media_content_type
if (
currentId.media_content_id &&
isManualMediaSourceContentId(currentId.media_content_id)
) {
this._currentItem = MANUAL_ITEM;
fireEvent(this, "media-browsed", {
ids: navigateIds,
current: this._currentItem,
});
} else {
if (!currentProm) {
currentProm = this._fetchData(
this.entityId,
currentId.media_content_id,
currentId.media_content_type
);
}
currentProm.then(
(item) => {
this._currentItem = item;
fireEvent(this, "media-browsed", {
ids: navigateIds,
current: item,
});
},
(err) => {
// When we change entity ID, we will first try to see if the new entity is
// able to resolve the new path. If that results in an error, browse the root.
const isNewEntityWithSamePath =
oldNavigateIds &&
changedProps.has("entityId") &&
navigateIds.length === oldNavigateIds.length &&
oldNavigateIds.every(
(oldItem, idx) =>
navigateIds[idx].media_content_id ===
oldItem.media_content_id &&
navigateIds[idx].media_content_type ===
oldItem.media_content_type
);
if (isNewEntityWithSamePath) {
fireEvent(this, "media-browsed", {
ids: [
{ media_content_id: undefined, media_content_type: undefined },
],
replace: true,
});
} else if (
err.code === "entity_not_found" &&
this.entityId &&
isUnavailableState(this.hass.states[this.entityId]?.state)
) {
this._setError({
message: this.hass.localize(
`ui.components.media-browser.media_player_unavailable`
),
code: "entity_not_found",
});
} else {
this._setError(err);
}
}
);
}
currentProm.then(
(item) => {
this._currentItem = item;
fireEvent(this, "media-browsed", {
ids: navigateIds,
current: item,
});
},
(err) => {
// When we change entity ID, we will first try to see if the new entity is
// able to resolve the new path. If that results in an error, browse the root.
const isNewEntityWithSamePath =
oldNavigateIds &&
changedProps.has("entityId") &&
navigateIds.length === oldNavigateIds.length &&
oldNavigateIds.every(
(oldItem, idx) =>
navigateIds[idx].media_content_id === oldItem.media_content_id &&
navigateIds[idx].media_content_type === oldItem.media_content_type
);
if (isNewEntityWithSamePath) {
fireEvent(this, "media-browsed", {
ids: [
{ media_content_id: undefined, media_content_type: undefined },
],
replace: true,
});
} else if (
err.code === "entity_not_found" &&
this.entityId &&
isUnavailableState(this.hass.states[this.entityId]?.state)
) {
this._setError({
message: this.hass.localize(
`ui.components.media-browser.media_player_unavailable`
),
code: "entity_not_found",
});
} else {
this._setError(err);
}
}
);
// Fetch parent
if (!parentProm && parentId !== undefined) {
parentProm = this._fetchData(
@@ -479,111 +514,120 @@ export class HaMediaPlayerBrowse extends LitElement {
</ha-alert>
</div>
`
: isTTSMediaSource(currentItem.media_content_id)
? html`
<ha-browse-media-tts
.item=${currentItem}
.hass=${this.hass}
.action=${this.action}
@tts-picked=${this._ttsPicked}
></ha-browse-media-tts>
`
: !children.length && !currentItem.not_shown
: isManualMediaSourceContentId(currentItem.media_content_id)
? html`<ha-browse-media-manual
.item=${{
media_content_id: this.defaultId || "",
media_content_type: this.defaultType || "",
}}
.hass=${this.hass}
@manual-media-picked=${this._manualPicked}
></ha-browse-media-manual>`
: isTTSMediaSource(currentItem.media_content_id)
? html`
<div class="container no-items">
${currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
`
: this.hass.localize(
"ui.components.media-browser.no_items"
)}
</div>
<ha-browse-media-tts
.item=${currentItem}
.hass=${this.hass}
.action=${this.action}
@tts-picked=${this._ttsPicked}
></ha-browse-media-tts>
`
: this.preferredLayout === "grid" ||
(this.preferredLayout === "auto" &&
childrenMediaClass.layout === "grid")
: !children.length && !currentItem.not_shown
? html`
<lit-virtualizer
scroller
.layout=${grid({
itemSize: {
width: "175px",
height:
childrenMediaClass.thumbnail_ratio ===
"portrait"
? "312px"
: "225px",
},
gap: "16px",
flex: { preserve: "aspect-ratio" },
justify: "space-evenly",
direction: "vertical",
})}
.items=${children}
.renderItem=${this._renderGridItem}
class="children ${classMap({
portrait:
childrenMediaClass.thumbnail_ratio ===
"portrait",
not_shown: !!currentItem.not_shown,
})}"
></lit-virtualizer>
${currentItem.not_shown
? html`
<div class="grid not-shown">
<div class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
<div class="container no-items">
${currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
</div>
`
: ""}
`
: this.hass.localize(
"ui.components.media-browser.no_items"
)}
</div>
`
: html`
<ha-list>
: this.preferredLayout === "grid" ||
(this.preferredLayout === "auto" &&
childrenMediaClass.layout === "grid")
? html`
<lit-virtualizer
scroller
.items=${children}
style=${styleMap({
height: `${children.length * 72 + 26}px`,
.layout=${grid({
itemSize: {
width: "175px",
height:
childrenMediaClass.thumbnail_ratio ===
"portrait"
? "312px"
: "225px",
},
gap: "16px",
flex: { preserve: "aspect-ratio" },
justify: "space-evenly",
direction: "vertical",
})}
.renderItem=${this._renderListItem}
.items=${children}
.renderItem=${this._renderGridItem}
class="children ${classMap({
portrait:
childrenMediaClass.thumbnail_ratio ===
"portrait",
not_shown: !!currentItem.not_shown,
})}"
></lit-virtualizer>
${currentItem.not_shown
? html`
<ha-list-item
noninteractive
class="not-shown"
.graphic=${mediaClass.show_list_images
? "medium"
: "avatar"}
>
<span class="title">
<div class="grid not-shown">
<div class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
</span>
</ha-list-item>
</div>
</div>
`
: ""}
</ha-list>
`
`
: html`
<ha-list>
<lit-virtualizer
scroller
.items=${children}
style=${styleMap({
height: `${children.length * 72 + 26}px`,
})}
.renderItem=${this._renderListItem}
></lit-virtualizer>
${currentItem.not_shown
? html`
<ha-list-item
noninteractive
class="not-shown"
.graphic=${mediaClass.show_list_images
? "medium"
: "avatar"}
>
<span class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
</span>
</ha-list-item>
`
: ""}
</ha-list>
`
}
</div>
</div>
@@ -617,8 +661,9 @@ export class HaMediaPlayerBrowse extends LitElement {
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${MediaClassBrowserSettings[
class=${child.iconPath ? "icon" : "folder"}
.path=${child.iconPath ||
MediaClassBrowserSettings[
child.media_class === "directory"
? child.children_media_class || child.media_class
: child.media_class
@@ -768,6 +813,14 @@ export class HaMediaPlayerBrowse extends LitElement {
});
}
private _manualPicked(ev: CustomEvent<ManualMediaPickedEvent>) {
ev.stopPropagation();
fireEvent(this, "media-picked", {
item: ev.detail.item as MediaPlayerItem,
navigateIds: this.navigateIds,
});
}
private _childClicked = async (ev: MouseEvent): Promise<void> => {
const target = ev.currentTarget as any;
const item: MediaPlayerItem = target.item;
@@ -791,9 +844,23 @@ export class HaMediaPlayerBrowse extends LitElement {
mediaContentId?: string,
mediaContentType?: string
): Promise<MediaPlayerItem> {
return entityId && entityId !== BROWSER_PLAYER
? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType)
: browseLocalMediaPlayer(this.hass, mediaContentId);
const prom =
entityId && entityId !== BROWSER_PLAYER
? browseMediaPlayer(
this.hass,
entityId,
mediaContentId,
mediaContentType
)
: browseLocalMediaPlayer(this.hass, mediaContentId);
return prom.then((item) => {
if (!mediaContentId && this.action === "pick") {
item.children = item.children || [];
item.children.push(MANUAL_ITEM);
}
return item;
});
}
private _measureCard(): void {
@@ -1141,6 +1208,11 @@ export class HaMediaPlayerBrowse extends LitElement {
--mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4);
}
.child .icon {
color: #00a9f7; /* Match the png color from brands repo */
--mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4);
}
.child .play {
position: absolute;
transition: color 0.5s;

View File

@@ -12,6 +12,8 @@ export interface MediaPlayerBrowseDialogParams {
navigateIds?: MediaPlayerItemId[];
minimumNavigateLevel?: number;
accept?: string[];
defaultId?: string;
defaultType?: string;
}
export const showMediaBrowserDialog = (

View File

@@ -1,20 +1,21 @@
import type { TemplateResult } from "lit";
import { html, css, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-tile-info")
export class HaTileInfo extends LitElement {
@property() public primary?: string;
@property() public secondary?: string | TemplateResult<1>;
@property() public secondary?: string;
protected render() {
return html`
<div class="info">
<span class="primary">${this.primary}</span>
${this.secondary
? html`<span class="secondary">${this.secondary}</span>`
: nothing}
<slot name="primary" class="primary">
<span>${this.primary}</span>
</slot>
<slot name="secondary" class="secondary">
<span>${this.secondary}</span>
</slot>
</div>
`;
}
@@ -27,7 +28,8 @@ export class HaTileInfo extends LitElement {
align-items: flex-start;
justify-content: center;
}
span {
span,
::slotted(*) {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;

View File

@@ -116,10 +116,6 @@ export interface SwitchAsXEntityOptions {
invert: boolean;
}
export interface RecorderEntityOptions {
recording_disabled_by?: string | null;
}
export interface EntityRegistryOptions {
number?: NumberEntityOptions;
sensor?: SensorEntityOptions;
@@ -128,7 +124,6 @@ export interface EntityRegistryOptions {
weather?: WeatherEntityOptions;
light?: LightEntityOptions;
switch_as_x?: SwitchAsXEntityOptions;
recorder?: RecorderEntityOptions;
conversation?: Record<string, unknown>;
"cloud.alexa"?: Record<string, unknown>;
"cloud.google_assistant"?: Record<string, unknown>;

View File

@@ -8,12 +8,12 @@ import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { autoCaseNoun } from "../common/translations/auto_case_noun";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HaEntityPickerEntityFilterFunc } from "../components/entity/ha-entity-picker";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE, UNKNOWN } from "./entity";
import { isNumericEntity } from "./history";
const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages";
export const CONTINUOUS_DOMAINS = ["counter", "proximity", "sensor", "zone"];
export const CONTINUOUS_DOMAINS = ["counter", "proximity"];
export interface LogbookStreamMessage {
events: LogbookEntry[];
@@ -326,9 +326,14 @@ export const localizeStateMessage = (
});
};
export const filterLogbookCompatibleEntities: HaEntityPickerEntityFilterFunc = (
entity
) =>
computeStateDomain(entity) !== "sensor" ||
(entity.attributes.unit_of_measurement === undefined &&
entity.attributes.state_class === undefined);
export const filterLogbookCompatibleEntities = (
entity,
sensorNumericDeviceClasses: string[] = []
) => {
const domain = computeStateDomain(entity);
const continuous =
CONTINUOUS_DOMAINS.includes(domain) ||
(domain === "sensor" &&
isNumericEntity(domain, entity, undefined, sensorNumericDeviceClasses));
return !continuous;
};

View File

@@ -14,4 +14,5 @@ export interface LovelaceCardConfig {
type: string;
[key: string]: any;
visibility?: Condition[];
disabled?: boolean;
}

View File

@@ -4,6 +4,7 @@ import type { LovelaceStrategyConfig } from "./strategy";
export interface LovelaceBaseSectionConfig {
visibility?: Condition[];
disabled?: boolean;
column_span?: number;
row_span?: number;
/**

View File

@@ -199,10 +199,12 @@ export interface MediaPlayerItem {
media_content_type: string;
media_content_id: string;
media_class: keyof TranslationDict["ui"]["components"]["media-browser"]["class"];
children_media_class?: string;
children_media_class?: string | null;
can_play: boolean;
can_expand: boolean;
can_search: boolean;
thumbnail?: string;
iconPath?: string;
children?: MediaPlayerItem[];
not_shown?: number;
}

View File

@@ -24,6 +24,11 @@ export const browseLocalMediaPlayer = (
media_content_id: mediaContentId,
});
export const MANUAL_MEDIA_SOURCE_PREFIX = "__MANUAL_ENTRY__";
export const isManualMediaSourceContentId = (mediaContentId: string) =>
mediaContentId.startsWith(MANUAL_MEDIA_SOURCE_PREFIX);
export const isMediaSourceContentId = (mediaId: string) =>
mediaId.startsWith("media-source://");

View File

@@ -365,36 +365,3 @@ export const isExternalStatistic = (statisticsId: string): boolean =>
export const updateStatisticsIssues = (hass: HomeAssistant) =>
hass.callWS<undefined>({ type: "recorder/update_statistics_issues" });
export interface EntityRecordingSettings {
recording_disabled_by: string | null;
}
export type EntityRecordingList = Record<string, EntityRecordingSettings>;
export const getEntityRecordingList = (hass: HomeAssistant) =>
hass
.callWS<{ recorded_entities: EntityRecordingList }>({
type: "homeassistant/record_entity/list",
})
.then((response) => response.recorded_entities);
export const getEntityRecordingSettings = (
hass: HomeAssistant,
entity_id: string
) =>
hass.callWS<EntityRecordingSettings>({
type: "homeassistant/record_entity/get",
entity_id,
});
export const setEntityRecordingOptions = (
hass: HomeAssistant,
entity_ids: string[],
recording_disabled_by: string | null
) =>
hass.callWS<undefined>({
type: "homeassistant/record_entity/set_options",
entity_ids,
recording_disabled_by,
});

View File

@@ -1,8 +1,5 @@
import { ensureArray } from "../../common/array/ensure-array";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type { HomeAssistant } from "../../types";
import type { Selector } from "../selector";
@@ -79,10 +76,8 @@ export const formatSelectorValue = (
if (!stateObj) {
return entityId;
}
const { device } = getEntityContext(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const entityName = computeEntityName(stateObj, hass);
return [deviceName, entityName].filter(Boolean).join(" ") || entityId;
const name = hass.formatEntityName(stateObj, ["device", "entity"], " ");
return name || entityId;
})
.join(", ");
}

View File

@@ -1,6 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-bottom-sheet";
import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-icon";
import "../../components/ha-md-list";
@@ -40,6 +41,54 @@ export class ListItemsDialog
return nothing;
}
const content = html`
<div class="container">
<ha-md-list>
${this._params.items.map(
(item) => html`
<ha-md-list-item
type="button"
@click=${this._itemClicked}
.item=${item}
>
${item.iconPath
? html`
<ha-svg-icon
.path=${item.iconPath}
slot="start"
class="item-icon"
></ha-svg-icon>
`
: item.icon
? html`
<ha-icon
icon=${item.icon}
slot="start"
class="item-icon"
></ha-icon>
`
: nothing}
<span class="headline">${item.label}</span>
${item.description
? html`
<span class="supporting-text">${item.description}</span>
`
: nothing}
</ha-md-list-item>
`
)}
</ha-md-list>
</div>
`;
if (this._params.mode === "bottom-sheet") {
return html`
<ha-bottom-sheet placement="bottom" open @closed=${this._dialogClosed}>
${content}
</ha-bottom-sheet>
`;
}
return html`
<ha-dialog
open
@@ -47,43 +96,7 @@ export class ListItemsDialog
@closed=${this._dialogClosed}
hideActions
>
<div class="container">
<ha-md-list>
${this._params.items.map(
(item) => html`
<ha-md-list-item
type="button"
@click=${this._itemClicked}
.item=${item}
>
${item.iconPath
? html`
<ha-svg-icon
.path=${item.iconPath}
slot="start"
class="item-icon"
></ha-svg-icon>
`
: item.icon
? html`
<ha-icon
icon=${item.icon}
slot="start"
class="item-icon"
></ha-icon>
`
: nothing}
<span class="headline">${item.label}</span>
${item.description
? html`
<span class="supporting-text">${item.description}</span>
`
: nothing}
</ha-md-list-item>
`
)}
</ha-md-list>
</div>
${content}
</ha-dialog>
`;
}

View File

@@ -11,6 +11,7 @@ interface ListItem {
export interface ListItemsDialogParams {
title?: string;
items: ListItem[];
mode?: "dialog" | "bottom-sheet";
}
export const showListItemsDialog = (

View File

@@ -116,7 +116,8 @@ export const computeShowLogBookComponent = (
const domain = computeDomain(entityId);
if (
(CONTINUOUS_DOMAINS.includes(domain) &&
CONTINUOUS_DOMAINS.includes(domain) ||
(domain === "sensor" &&
isNumericEntity(
domain,
stateObj,

View File

@@ -23,14 +23,8 @@ import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import {
computeEntityEntryName,
computeEntityName,
} from "../../common/entity/compute_entity_name";
import {
getEntityContext,
getEntityEntryContext,
} from "../../common/entity/context/get_entity_context";
import { computeEntityEntryName } from "../../common/entity/compute_entity_name";
import { getEntityEntryContext } from "../../common/entity/context/get_entity_context";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import { navigate } from "../../common/navigate";
import "../../components/ha-button-menu";
@@ -322,22 +316,28 @@ export class MoreInfoDialog extends LitElement {
(isDefaultView && this._parentEntityIds.length === 0) ||
isSpecificInitialView;
const context = stateObj
? getEntityContext(stateObj, this.hass)
: this._entry
? getEntityEntryContext(this._entry, this.hass)
: undefined;
let entityName: string | undefined;
let deviceName: string | undefined;
let areaName: string | undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass)
: this._entry
? computeEntityEntryName(this._entry, this.hass)
: entityId;
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
if (stateObj) {
entityName = this.hass.formatEntityName(stateObj, "entity");
deviceName = this.hass.formatEntityName(stateObj, "device");
areaName = this.hass.formatEntityName(stateObj, "area");
} else if (this._entry) {
const { device, area } = getEntityEntryContext(
this._entry,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
entityName = computeEntityEntryName(this._entry, this.hass.devices);
deviceName = device ? computeDeviceName(device) : undefined;
areaName = area ? computeAreaName(area) : undefined;
} else {
entityName = entityId;
}
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)

View File

@@ -9,7 +9,6 @@ import type {
} from "../../data/entity_registry";
import { PLATFORMS_WITH_SETTINGS_TAB } from "../../panels/config/entities/const";
import "../../panels/config/entities/entity-registry-settings";
import "../../panels/config/entities/entity-settings-without-unique-id";
import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
@@ -29,7 +28,7 @@ export class HaMoreInfoSettings extends LitElement {
return nothing;
}
// No unique ID - show limited settings
// No unique ID
if (this.entry === null) {
return html`
<div class="content">
@@ -44,10 +43,6 @@ export class HaMoreInfoSettings extends LitElement {
>`,
})}
</ha-alert>
<entity-settings-without-unique-id
.hass=${this.hass}
.entityId=${this.entityId}
></entity-settings-without-unique-id>
</div>
`;
}

View File

@@ -21,15 +21,10 @@ import { componentsWithService } from "../../common/config/components_with_servi
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { ScorableTextItem } from "../../common/string/filter/sequence-matching";
@@ -635,12 +630,10 @@ export class QuickBar extends LitElement {
.map((entityId) => {
const stateObj = this.hass.states[entityId];
const { area, device } = getEntityContext(stateObj, this.hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, this.hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const entityName = this.hass.formatEntityName(stateObj, "entity");
const deviceName = this.hass.formatEntityName(stateObj, "device");
const areaName = this.hass.formatEntityName(stateObj, "area");
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]

View File

@@ -114,17 +114,22 @@ export const provideHass = (
formatEntityState,
formatEntityAttributeName,
formatEntityAttributeValue,
formatEntityName,
} = await computeFormatFunctions(
hass().localize,
hass().locale,
hass().config,
hass().entities,
hass().devices,
hass().areas,
hass().floors,
[] // numericDeviceClasses
);
hass().updateHass({
formatEntityState,
formatEntityAttributeName,
formatEntityAttributeValue,
formatEntityName,
});
}

View File

@@ -148,19 +148,36 @@ class HassSubpage extends LitElement {
.content {
position: relative;
width: 100%;
height: calc(100% - 1px - var(--header-height));
width: calc(100% - var(--safe-area-inset-right, 0px));
height: calc(
100% -
1px - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
margin-bottom: var(--safe-area-inset-bottom);
margin-right: var(--safe-area-inset-right);
overflow-y: auto;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
:host([narrow]) .content {
width: calc(
100% - var(--safe-area-inset-left, 0px) - var(
--safe-area-inset-right,
0px
)
);
margin-left: var(--safe-area-inset-left);
}
#fab {
position: absolute;
right: calc(16px + var(--safe-area-inset-right));
inset-inline-end: calc(16px + var(--safe-area-inset-right));
right: calc(16px + var(--safe-area-inset-right, 0px));
inset-inline-end: calc(16px + var(--safe-area-inset-right, 0px));
inset-inline-start: initial;
bottom: calc(16px + var(--safe-area-inset-bottom));
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
z-index: 1;
display: flex;
flex-wrap: wrap;
@@ -168,12 +185,12 @@ class HassSubpage extends LitElement {
gap: 8px;
}
:host([narrow]) #fab.tabs {
bottom: calc(84px + var(--safe-area-inset-bottom));
bottom: calc(84px + var(--safe-area-inset-bottom, 0px));
}
#fab[is-wide] {
bottom: 24px;
right: 24px;
inset-inline-end: 24px;
bottom: calc(24px + var(--safe-area-inset-bottom, 0px));
right: calc(24px + var(--safe-area-inset-right, 0px));
inset-inline-end: calc(24px + var(--safe-area-inset-right, 0px));
inset-inline-start: initial;
}
`,

View File

@@ -704,12 +704,24 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
}
:host(:not([narrow])) ha-data-table,
.pane {
height: calc(100vh - 1px - var(--header-height));
height: calc(
100vh -
1px - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
display: block;
}
.pane-content {
height: calc(100vh - 1px - var(--header-height) - var(--header-height));
height: calc(
100vh -
1px - var(--header-height, 0px) - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
display: flex;
flex-direction: column;
}

View File

@@ -173,7 +173,9 @@ class HassTabsSubpage extends LitElement {
? html`<div id="tabbar" class="bottom-bar">${tabs}</div>`
: ""}
</div>
<div class="container">
<div
class=${classMap({ container: true, tabs: showTabs && this.narrow })}
>
${this.pane
? html`<div class="pane">
<div class="shadow-container"></div>
@@ -226,11 +228,9 @@ class HassTabsSubpage extends LitElement {
.container {
display: flex;
height: calc(100% - var(--header-height));
}
:host([narrow]) .container {
height: 100%;
height: calc(
100% - var(--header-height, 0px) - var(--safe-area-inset-top, 0px)
);
}
ha-menu-button {
@@ -241,15 +241,19 @@ class HassTabsSubpage extends LitElement {
.toolbar {
font-size: var(--ha-font-size-xl);
height: calc(var(--header-height) + var(--safe-area-inset-top));
height: calc(
var(--header-height, 0px) + var(--safe-area-inset-top, 0px)
);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
padding-left: var(--safe-area-inset-left);
background-color: var(--sidebar-background-color);
font-weight: var(--ha-font-weight-normal);
border-bottom: 1px solid var(--divider-color);
box-sizing: border-box;
}
:host([narrow]) .toolbar {
padding-left: var(--safe-area-inset-left);
}
.toolbar-content {
padding: 8px 12px;
display: flex;
@@ -257,10 +261,8 @@ class HassTabsSubpage extends LitElement {
height: 100%;
box-sizing: border-box;
}
@media (max-width: 599px) {
.toolbar-content {
padding: 4px;
}
:host([narrow]) .toolbar-content {
padding: 4px;
}
.toolbar a {
color: var(--sidebar-text-color);
@@ -324,45 +326,38 @@ class HassTabsSubpage extends LitElement {
.content {
position: relative;
width: calc(
100% - var(--safe-area-inset-left) - var(--safe-area-inset-right)
);
margin-left: var(--safe-area-inset-left);
width: 100%;
margin-right: var(--safe-area-inset-right);
margin-inline-start: var(--safe-area-inset-left);
margin-inline-end: var(--safe-area-inset-right);
margin-bottom: var(--safe-area-inset-bottom);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
:host([narrow]) .content {
height: calc(100% - var(--header-height));
height: calc(
100% - var(--header-height) - var(--safe-area-inset-bottom)
);
margin-left: var(--safe-area-inset-left);
margin-inline-start: var(--safe-area-inset-left);
}
:host([narrow]) .content.tabs {
height: calc(100% - 2 * var(--header-height));
height: calc(
100% - 2 * var(--header-height) - var(--safe-area-inset-bottom)
/* Bottom bar reuses header height */
margin-bottom: calc(
var(--header-height, 0px) + var(--safe-area-inset-bottom, 0px)
);
}
.content .fab-bottom-space {
height: calc(64px + var(--safe-area-inset-bottom));
height: calc(64px + var(--safe-area-inset-bottom, 0px));
}
:host([narrow]) .content.tabs .fab-bottom-space {
height: calc(80px + var(--safe-area-inset-bottom));
height: calc(80px + var(--safe-area-inset-bottom, 0px));
}
#fab {
position: fixed;
right: calc(16px + var(--safe-area-inset-right));
right: calc(16px + var(--safe-area-inset-right, 0px));
inset-inline-end: calc(16px + var(--safe-area-inset-right));
inset-inline-start: initial;
bottom: calc(16px + var(--safe-area-inset-bottom));
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
z-index: 1;
display: flex;
flex-wrap: wrap;
@@ -370,7 +365,7 @@ class HassTabsSubpage extends LitElement {
gap: 8px;
}
:host([narrow]) #fab.tabs {
bottom: calc(84px + var(--safe-area-inset-bottom));
bottom: calc(84px + var(--safe-area-inset-bottom, 0px));
}
#fab[is-wide] {
bottom: 24px;

View File

@@ -1,5 +1,5 @@
import { mdiCalendarClock } from "@mdi/js";
import { toDate } from "date-fns-tz";
import { TZDate } from "@date-fns/tz";
import { addDays, isSameDay } from "date-fns";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -143,8 +143,8 @@ class DialogCalendarEventDetail extends LitElement {
this.hass.locale.time_zone,
this.hass.config.time_zone
);
const start = toDate(this._data!.dtstart, { timeZone: timeZone });
const endValue = toDate(this._data!.dtend, { timeZone: timeZone });
const start = new TZDate(this._data!.dtstart, timeZone);
const endValue = new TZDate(this._data!.dtend, timeZone);
// All day events should be displayed as a day earlier
const end = isDate(this._data.dtend) ? addDays(endValue, -1) : endValue;
// The range can be shortened when the start and end are on the same day.

View File

@@ -5,7 +5,6 @@ import {
differenceInMilliseconds,
startOfHour,
} from "date-fns";
import { formatInTimeZone, toDate } from "date-fns-tz";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -40,6 +39,11 @@ import "../lovelace/components/hui-generic-entity-row";
import "./ha-recurrence-rule-editor";
import { showConfirmEventDialog } from "./show-confirm-event-dialog-box";
import type { CalendarEventEditDialogParams } from "./show-dialog-calendar-event-editor";
import {
formatDate,
formatTime,
parseDate,
} from "../../common/datetime/calc_date";
const CALENDAR_DOMAINS = ["calendar"];
@@ -303,28 +307,13 @@ class DialogCalendarEventEditor extends LitElement {
private _getLocaleStrings = memoizeOne(
(startDate?: Date, endDate?: Date) => ({
startDate: this._formatDate(startDate!),
startTime: this._formatTime(startDate!),
endDate: this._formatDate(endDate!),
endTime: this._formatTime(endDate!),
startDate: formatDate(startDate!, this._timeZone!),
startTime: formatTime(startDate!, this._timeZone!),
endDate: formatDate(endDate!, this._timeZone!),
endTime: formatTime(endDate!, this._timeZone!),
})
);
// Formats a date in specified timezone, or defaulting to browser display timezone
private _formatDate(date: Date, timeZone: string = this._timeZone!): string {
return formatInTimeZone(date, timeZone, "yyyy-MM-dd");
}
// Formats a time in specified timezone, or defaulting to browser display timezone
private _formatTime(date: Date, timeZone: string = this._timeZone!): string {
return formatInTimeZone(date, timeZone, "HH:mm:ss"); // 24 hr
}
// Parse a date in the browser timezone
private _parseDate(dateStr: string): Date {
return toDate(dateStr, { timeZone: this._timeZone! });
}
private _clearInfo() {
this._info = undefined;
}
@@ -349,8 +338,9 @@ class DialogCalendarEventEditor extends LitElement {
// Store previous event duration
const duration = differenceInMilliseconds(this._dtend!, this._dtstart!);
this._dtstart = this._parseDate(
`${ev.detail.value}T${this._formatTime(this._dtstart!)}`
this._dtstart = parseDate(
`${ev.detail.value}T${formatTime(this._dtstart!, this._timeZone!)}`,
this._timeZone!
);
// Prevent that the end time can be before the start time. Try to keep the
@@ -364,8 +354,9 @@ class DialogCalendarEventEditor extends LitElement {
}
private _endDateChanged(ev: CustomEvent) {
this._dtend = this._parseDate(
`${ev.detail.value}T${this._formatTime(this._dtend!)}`
this._dtend = parseDate(
`${ev.detail.value}T${formatTime(this._dtend!, this._timeZone!)}`,
this._timeZone!
);
}
@@ -373,8 +364,9 @@ class DialogCalendarEventEditor extends LitElement {
// Store previous event duration
const duration = differenceInMilliseconds(this._dtend!, this._dtstart!);
this._dtstart = this._parseDate(
`${this._formatDate(this._dtstart!)}T${ev.detail.value}`
this._dtstart = parseDate(
`${formatDate(this._dtstart!, this._timeZone!)}T${ev.detail.value}`,
this._timeZone!
);
// Prevent that the end time can be before the start time. Try to keep the
@@ -388,8 +380,9 @@ class DialogCalendarEventEditor extends LitElement {
}
private _endTimeChanged(ev: CustomEvent) {
this._dtend = this._parseDate(
`${this._formatDate(this._dtend!)}T${ev.detail.value}`
this._dtend = parseDate(
`${formatDate(this._dtend!, this._timeZone!)}T${ev.detail.value}`,
this._timeZone!
);
}
@@ -402,18 +395,18 @@ class DialogCalendarEventEditor extends LitElement {
dtend: "",
};
if (this._allDay) {
data.dtstart = this._formatDate(this._dtstart!);
data.dtstart = formatDate(this._dtstart!, this._timeZone!);
// End date/time is exclusive when persisted
data.dtend = this._formatDate(addDays(this._dtend!, 1));
data.dtend = formatDate(addDays(this._dtend!, 1), this._timeZone!);
} else {
data.dtstart = `${this._formatDate(
data.dtstart = `${formatDate(
this._dtstart!,
this.hass.config.time_zone
)}T${this._formatTime(this._dtstart!, this.hass.config.time_zone)}`;
data.dtend = `${this._formatDate(
)}T${formatTime(this._dtstart!, this.hass.config.time_zone)}`;
data.dtend = `${formatDate(
this._dtend!,
this.hass.config.time_zone
)}T${this._formatTime(this._dtend!, this.hass.config.time_zone)}`;
)}T${formatTime(this._dtend!, this.hass.config.time_zone)}`;
}
return data;
}

View File

@@ -161,6 +161,8 @@ export class HAFullCalendar extends LitElement {
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="small"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
`
@@ -195,6 +197,8 @@ export class HAFullCalendar extends LitElement {
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="small"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
</div>

View File

@@ -114,7 +114,11 @@ class PanelCalendar extends LitElement {
);
const showPane = this._showPaneController.value ?? !this.narrow;
return html`
<ha-two-pane-top-app-bar-fixed .pane=${showPane} footer>
<ha-two-pane-top-app-bar-fixed
.pane=${showPane}
footer
.narrow=${this.narrow}
>
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
@@ -294,10 +298,15 @@ class PanelCalendar extends LitElement {
display: block;
}
ha-full-calendar {
height: calc(100vh - var(--header-height));
--calendar-header-padding: 12px;
--calendar-border-radius: 0;
--calendar-border-width: 1px 0;
height: calc(
100vh - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
}
ha-button-menu ha-button {
--ha-font-size-m: var(--ha-font-size-l);

View File

@@ -1,5 +1,5 @@
import type { SelectedDetail } from "@material/mwc-list";
import { formatInTimeZone, toDate } from "date-fns-tz";
import { TZDate } from "@date-fns/tz";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -33,6 +33,7 @@ import {
ruleByWeekDay,
untilValue,
} from "./recurrence";
import { formatDate, formatTime } from "../../common/datetime/calc_date";
@customElement("ha-recurrence-rule-editor")
export class RecurrenceRuleEditor extends LitElement {
@@ -168,7 +169,9 @@ export class RecurrenceRuleEditor extends LitElement {
}
if (rrule.until) {
this._end = "on";
this._untilDay = toDate(rrule.until, { timeZone: this.timezone });
this._untilDay = new Date(
new TZDate(rrule.until, this.timezone).getTime()
);
} else if (rrule.count) {
this._end = "after";
this._count = rrule.count;
@@ -335,7 +338,7 @@ export class RecurrenceRuleEditor extends LitElement {
"ui.components.calendar.event.repeat.end_on.label"
)}
.locale=${this.locale}
.value=${this._formatDate(this._untilDay!)}
.value=${formatDate(this._untilDay!, this.timezone!)}
@value-changed=${this._onUntilChange}
></ha-date-input>
`
@@ -421,9 +424,9 @@ export class RecurrenceRuleEditor extends LitElement {
private _onUntilChange(e: CustomEvent) {
e.stopPropagation();
this._untilDay = toDate(e.detail.value + "T00:00:00", {
timeZone: this.timezone,
});
this._untilDay = new Date(
new TZDate(e.detail.value + "T00:00:00", this.timezone).getTime()
);
}
// Reset the weekday selected when there is only a single value
@@ -458,20 +461,22 @@ export class RecurrenceRuleEditor extends LitElement {
let contentline = RRule.optionsToString(options);
if (this._untilDay) {
// The UNTIL value should be inclusive of the last event instance
const until = toDate(
this._formatDate(this._untilDay!) +
const until = new TZDate(
formatDate(this._untilDay!, this.timezone!) +
"T" +
this._formatTime(this.dtstart!),
{ timeZone: this.timezone }
formatTime(this.dtstart!, this.timezone!),
this.timezone
);
// rrule.js can't compute some UNTIL variations so we compute that ourself. Must be
// in the same format as dtstart.
const format = this.allDay ? "yyyyMMdd" : "yyyyMMdd'T'HHmmss";
const newUntilValue = formatInTimeZone(
until,
this.hass.config.time_zone,
format
);
let newUntilValue;
if (this.allDay) {
// For all-day events, only use the date part
newUntilValue = until.toISOString().split("T")[0].replace(/-/g, "");
} else {
// For timed events, include the time part
newUntilValue = until.toISOString().replace(/[-:]/g, "").split(".")[0];
}
contentline += `;UNTIL=${newUntilValue}`;
}
return contentline.slice(6); // Strip "RRULE:" prefix
@@ -492,16 +497,6 @@ export class RecurrenceRuleEditor extends LitElement {
);
}
// Formats a date in browser display timezone
private _formatDate(date: Date): string {
return formatInTimeZone(date, this.timezone!, "yyyy-MM-dd");
}
// Formats a time in browser display timezone
private _formatTime(date: Date): string {
return formatInTimeZone(date, this.timezone!, "HH:mm:ss");
}
static styles = css`
ha-textfield,
ha-select {

View File

@@ -94,11 +94,25 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
:host {
position: relative;
height: 100%;
min-height: calc(100vh - 85px);
min-height: calc(100dvh - 85px);
min-height: calc(
100vh -
134px - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
0px
)
);
min-height: calc(
100dvh -
134px - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
0px
)
);
}
ha-fab {
position: fixed;
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
right: calc(16px + var(--safe-area-inset-right, 0px));
}
`,
];

View File

@@ -1214,12 +1214,12 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
}
ha-fab {
position: fixed;
right: 16px;
right: calc(16px + var(--safe-area-inset-right, 0px));
bottom: calc(-80px - var(--safe-area-inset-bottom));
transition: bottom 0.3s;
}
ha-fab.dirty {
bottom: 16px;
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
}
`,
];

View File

@@ -1,7 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import "../../../components/ha-bottom-sheet";
import type { HaBottomSheet } from "../../../components/ha-bottom-sheet";
import "../../../components/ha-resizable-bottom-sheet";
import type { HaResizableBottomSheet } from "../../../components/ha-resizable-bottom-sheet";
import {
isCondition,
isScriptField,
@@ -37,7 +37,8 @@ export default class HaAutomationSidebar extends LitElement {
@state() private _yamlMode = false;
@query("ha-bottom-sheet") private _bottomSheetElement?: HaBottomSheet;
@query("ha-resizable-bottom-sheet")
private _bottomSheetElement?: HaResizableBottomSheet;
private _renderContent() {
// get config type
@@ -147,9 +148,9 @@ export default class HaAutomationSidebar extends LitElement {
if (this.narrow) {
return html`
<ha-bottom-sheet @bottom-sheet-closed=${this._closeSidebar}>
<ha-resizable-bottom-sheet @bottom-sheet-closed=${this._closeSidebar}>
${this._renderContent()}
</ha-bottom-sheet>
</ha-resizable-bottom-sheet>
`;
}

View File

@@ -92,12 +92,12 @@ export const saveFabStyles = css`
}
ha-fab {
position: absolute;
right: 16px;
right: calc(16px + var(--safe-area-inset-right, 0px));
bottom: calc(-80px - var(--safe-area-inset-bottom));
transition: bottom 0.3s;
}
ha-fab.dirty {
bottom: 16px;
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
}
`;
@@ -126,7 +126,7 @@ export const manualEditorStyles = css`
transition: bottom 0.3s;
}
.fab-positioner ha-fab.dirty {
bottom: 16px;
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
}
.content-wrapper {

View File

@@ -118,6 +118,7 @@ class HaConfigSystemNavigation extends LitElement {
.hass=${this.hass}
back-path="/config"
.header=${this.hass.localize("ui.panel.config.dashboard.system.main")}
.narrow=${this.narrow}
>
<ha-icon-button
slot="toolbar-icon"

View File

@@ -212,7 +212,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
this._repairsIssues;
return html`
<ha-top-app-bar-fixed>
<ha-top-app-bar-fixed .narrow=${this.narrow}>
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
@@ -386,17 +386,16 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
return [
haStyle,
css`
ha-card:last-child {
margin-bottom: var(--safe-area-inset-bottom);
}
:host(:not([narrow])) ha-card:last-child {
margin-bottom: max(24px, var(--safe-area-inset-bottom));
margin-bottom: 24px;
}
ha-config-section {
margin: auto;
margin-top: -32px;
max-width: 600px;
}
ha-card {
overflow: hidden;
}
@@ -404,9 +403,11 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
text-decoration: none;
color: var(--primary-text-color);
}
ha-assist-chip {
margin: 8px 16px 16px 16px;
}
.title {
font-size: var(--ha-font-size-l);
padding: 16px;
@@ -425,7 +426,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
}
ha-tip {
margin-bottom: max(var(--safe-area-inset-bottom), 8px);
margin-bottom: 8px;
}
.new {

View File

@@ -172,7 +172,9 @@ export class HaDeviceEntitiesCard extends LitElement {
element.hass = this.hass;
const stateObj = this.hass.states[entry.entity_id];
let name = computeEntityName(stateObj, this.hass) || this.deviceName;
let name =
computeEntityName(stateObj, this.hass.entities, this.hass.devices) ||
this.deviceName;
if (entry.hidden_by) {
name += ` (${this.hass.localize(

View File

@@ -1238,7 +1238,7 @@ export class HaConfigDevicePage extends LitElement {
private _computeEntityName(entity: EntityRegistryEntry) {
const device = this.hass.devices[this.deviceId];
return (
computeEntityEntryName(entity, this.hass) ||
computeEntityEntryName(entity, this.hass.devices) ||
computeDeviceNameDisplay(device, this.hass)
);
}
@@ -1489,6 +1489,9 @@ export class HaConfigDevicePage extends LitElement {
margin-top: 32px;
margin-bottom: 32px;
}
:host([narrow]) .container {
margin-top: 0;
}
.card-header {
display: flex;
@@ -1588,10 +1591,6 @@ export class HaConfigDevicePage extends LitElement {
width: 100%;
}
:host([narrow]) .container {
margin-top: 0;
}
a {
text-decoration: none;
color: var(--primary-color);

View File

@@ -65,7 +65,6 @@ import {
updateEntityRegistryEntry,
} from "../../../data/entity_registry";
import { entityIcon, entryIcon } from "../../../data/icons";
import { handleRecordingChange } from "./recorder-util";
import {
domainToName,
fetchIntegrationManifest,
@@ -198,8 +197,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _noDeviceArea?: boolean;
@state() private _recordingDisabled?: boolean;
private _origEntityId!: string;
private _deviceClassOptions?: string[][];
@@ -230,9 +227,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
const domain = computeDomain(this.entry.entity_id);
// Fetch recording settings
this._fetchRecordingSettings();
if (domain === "camera" && isComponentLoaded(this.hass, "stream")) {
const stateObj: HassEntity | undefined =
this.hass.states[this.entry.entity_id];
@@ -978,27 +972,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
></ha-switch>
</ha-settings-row>
${isComponentLoaded(this.hass, "recorder")
? html`
<ha-settings-row>
<span slot="heading"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.record_label"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.record_description"
)}</span
>
<ha-switch
.checked=${!this._recordingDisabled}
.disabled=${this.disabled}
@change=${this._recordingChanged}
></ha-switch>
</ha-settings-row>
`
: ""}
${this.entry.device_id
? html`<ha-settings-row>
<span slot="heading"
@@ -1426,15 +1399,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._labels = ev.detail.value;
}
private _fetchRecordingSettings() {
if (!isComponentLoaded(this.hass, "recorder")) {
return;
}
// Get recording settings from entity registry entry options
const recorderOptions = this.entry.options?.recorder;
this._recordingDisabled = recorderOptions?.recording_disabled_by !== null;
}
private async _fetchCameraPrefs() {
const capabilities = await fetchCameraCapabilities(
this.hass,
@@ -1498,19 +1462,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
}
private async _recordingChanged(ev: CustomEvent): Promise<void> {
const checkbox = ev.currentTarget as HaSwitch;
await handleRecordingChange({
hass: this.hass,
entityId: this.entry.entity_id,
checkbox,
onSuccess: (recordingDisabled) => {
this._recordingDisabled = recordingDisabled;
},
});
}
private _openDeviceSettings() {
showDeviceRegistryDetailDialog(this, {
device: this._device!,

View File

@@ -1,126 +0,0 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-settings-row";
import "../../../components/ha-switch";
import type { HaSwitch } from "../../../components/ha-switch";
import "../../../components/ha-textfield";
import { getEntityRecordingSettings } from "../../../data/recorder";
import { handleRecordingChange } from "./recorder-util";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@customElement("entity-settings-without-unique-id")
export class EntitySettingsWithoutUniqueId extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "entity-id" }) public entityId!: string;
@state() private _recordingDisabled?: boolean;
protected firstUpdated() {
this._fetchRecordingSettings();
}
private async _fetchRecordingSettings() {
if (!isComponentLoaded(this.hass, "recorder")) {
return;
}
try {
const settings = await getEntityRecordingSettings(
this.hass,
this.entityId
);
this._recordingDisabled = settings?.recording_disabled_by !== null;
} catch (err: any) {
// eslint-disable-next-line no-console
console.error("Error fetching recording settings:", err);
}
}
protected render() {
const stateObj = this.hass.states[this.entityId];
const name = stateObj?.attributes?.friendly_name || this.entityId;
return html`
<ha-textfield
.label=${this.hass.localize("ui.dialogs.entity_registry.editor.name")}
.value=${name}
disabled
readonly
></ha-textfield>
<ha-textfield
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.entity_id"
)}
.value=${this.entityId}
disabled
readonly
></ha-textfield>
${isComponentLoaded(this.hass, "recorder")
? html`
<ha-settings-row>
<span slot="heading"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.record_label"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.record_description"
)}</span
>
<ha-switch
.checked=${this._recordingDisabled !== true}
@change=${this._recordingChanged}
></ha-switch>
</ha-settings-row>
`
: nothing}
`;
}
private async _recordingChanged(ev: CustomEvent): Promise<void> {
const checkbox = ev.currentTarget as HaSwitch;
await handleRecordingChange({
hass: this.hass,
entityId: this.entityId,
checkbox,
onSuccess: (recordingDisabled) => {
this._recordingDisabled = recordingDisabled;
// Fire event to notify entities table to refresh recording data
this.dispatchEvent(
new CustomEvent("entity-recording-updated", {
detail: { entityId: this.entityId },
bubbles: true,
composed: true,
})
);
},
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
margin-top: 16px;
}
ha-textfield {
display: block;
margin-bottom: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"entity-settings-without-unique-id": EntitySettingsWithoutUniqueId;
}
}

View File

@@ -23,7 +23,6 @@ import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import memoize from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -43,10 +42,7 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../../common/translations/localize";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
rejectedItems,
@@ -101,12 +97,6 @@ import {
subscribeLabelRegistry,
} from "../../../data/label_registry";
import { regenerateEntityIds } from "../../../data/regenerate_entity_ids";
import {
getEntityRecordingList,
getEntityRecordingSettings,
setEntityRecordingOptions,
type EntityRecordingList,
} from "../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -148,7 +138,6 @@ export interface EntityRow extends StateEntity {
enabled: string;
visible: string;
available: string;
recorded?: boolean;
}
@customElement("ha-config-entities")
@@ -204,8 +193,6 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
@state() private _entitySources?: EntitySources;
@state() private _recordingEntities?: EntityRecordingList;
@storage({ key: "entities-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@@ -240,20 +227,12 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
window.addEventListener(
"entity-recording-updated",
this._handleEntityRecordingUpdated
);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._popState);
window.removeEventListener(
"entity-recording-updated",
this._handleEntityRecordingUpdated
);
}
private _locationChanged = () => {
@@ -270,32 +249,6 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
}
};
private _handleEntityRecordingUpdated = async (ev: Event) => {
const customEvent = ev as CustomEvent<{ entityId: string }>;
// Update recording data for the specific entity that changed
if (this._activeHiddenColumns?.includes("recorded")) {
return;
}
try {
const settings = await getEntityRecordingSettings(
this.hass,
customEvent.detail.entityId
);
// Update the recording data for this specific entity
this._recordingEntities = {
...this._recordingEntities,
[customEvent.detail.entityId]: settings,
};
} catch (_err) {
// Entity might not have recording settings yet, treat as enabled
this._recordingEntities = {
...this._recordingEntities,
[customEvent.detail.entityId]: { recording_disabled_by: null },
};
}
};
private _states = memoize((localize: LocalizeFunc) => [
{
value: "available",
@@ -537,75 +490,9 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
template: (entry) =>
entry.label_entries.map((lbl) => lbl.name).join(" "),
},
recorded: {
title: localize("ui.panel.config.entities.picker.headers.recorded"),
type: "icon",
sortable: true,
filterable: true,
defaultHidden: true,
showNarrow: true,
minWidth: "86px",
maxWidth: "86px",
template: (entry) =>
entry.recorded === undefined
? "—"
: entry.recorded
? html`
<div
tabindex="0"
style="display:inline-block; position: relative;"
>
<ha-svg-icon
.id="recording-icon-${slugify(entry.entity_id)}"
.path=${mdiToggleSwitch}
style="color: var(--success-color)"
></ha-svg-icon>
<ha-tooltip
.for="recording-icon-${slugify(entry.entity_id)}"
placement="left"
>
${this.hass.localize(
"ui.panel.config.entities.picker.recorded.enabled"
)}
</ha-tooltip>
</div>
`
: html`
<div
tabindex="0"
style="display:inline-block; position: relative;"
>
<ha-svg-icon
.id="recording-icon-${slugify(entry.entity_id)}"
.path=${mdiToggleSwitchOffOutline}
style="color: var(--secondary-text-color)"
></ha-svg-icon>
<ha-tooltip
.for="recording-icon-${slugify(entry.entity_id)}"
placement="left"
>
${this.hass.localize(
"ui.panel.config.entities.picker.recorded.disabled"
)}
</ha-tooltip>
</div>
`,
},
})
);
private _hasNonUniqueIdEntities = memoize(
(selected: string[], filteredEntities: EntityRow[]) => {
// Create a Set of readonly entity IDs for O(1) lookup
const readonlyEntityIds = new Set(
filteredEntities
.filter((e) => e.readonly === true)
.map((e) => e.entity_id)
);
return selected.some((entityId) => readonlyEntityIds.has(entityId));
}
);
private _filteredEntitiesAndDomains = memoize(
(
localize: LocalizeFunc,
@@ -616,8 +503,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
filters: DataTableFiltersValues,
filteredItems: DataTableFiltersItems,
entries?: ConfigEntry[],
labelReg?: LabelRegistryEntry[],
recordingEntities?: EntityRecordingList
labelReg?: LabelRegistryEntry[]
) => {
const result: EntityRow[] = [];
@@ -796,7 +682,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
const entityName = computeEntityEntryName(
entry as EntityRegistryEntry,
this.hass
this.hass.devices,
entity
);
const deviceName = device ? computeDeviceName(device) : undefined;
@@ -808,15 +695,6 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
: deviceName
: undefined;
// Determine recording status
const recorded: boolean | undefined = recordingEntities
? recordingEntities[entry.entity_id]
? recordingEntities[entry.entity_id].recording_disabled_by === null
: entry.options?.recorder
? entry.options.recorder.recording_disabled_by === null
: true
: undefined;
result.push({
...entry,
entity,
@@ -853,7 +731,6 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
visible: hidden
? localize("ui.panel.config.entities.picker.status.hidden")
: localize("ui.panel.config.entities.picker.status.visible"),
recorded,
});
}
@@ -884,8 +761,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
this._filters,
this._filteredItems,
this._entries,
this._labels,
this._recordingEntities
this._labels
);
const includeAddDeviceFab =
@@ -894,54 +770,6 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
[...filteredDomains][0]
);
// Check if any selected entities are without unique IDs (memoized for performance)
const hasNonUniqueIdEntities = this._hasNonUniqueIdEntities(
this._selected,
filteredEntities
);
// Helper to render menu items that can be disabled for non-unique ID entities
const renderDisableableMenuItem = (
action: () => void,
icon: string,
labelKey: LocalizeKeys,
itemId: string,
warning = false
) => {
if (hasNonUniqueIdEntities) {
return html`
<div
id=${itemId}
style="position: relative; cursor: not-allowed;"
tabindex="0"
>
<ha-md-menu-item
.disabled=${true}
class=${warning ? "warning" : ""}
style="pointer-events: none; opacity: 0.5;"
>
<ha-svg-icon slot="start" .path=${icon}></ha-svg-icon>
<div slot="headline">${this.hass.localize(labelKey)}</div>
</ha-md-menu-item>
<ha-tooltip .for=${itemId} placement="left">
${this.hass.localize(
"ui.panel.config.entities.picker.non_unique_id_selected"
)}
</ha-tooltip>
</div>
`;
}
return html`
<ha-md-menu-item
.clickAction=${action}
class=${warning ? "warning" : ""}
>
<ha-svg-icon slot="start" .path=${icon}></ha-svg-icon>
<div slot="headline">${this.hass.localize(labelKey)}</div>
</ha-md-menu-item>
`;
};
const labelItems = html` ${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
@@ -1088,79 +916,77 @@ ${
: nothing
}
${renderDisableableMenuItem(
this._enableSelected,
mdiToggleSwitch,
"ui.panel.config.entities.picker.enable_selected.button",
"enable-selected-disabled"
)}
${renderDisableableMenuItem(
this._disableSelected,
mdiToggleSwitchOffOutline,
"ui.panel.config.entities.picker.disable_selected.button",
"disable-selected-disabled"
)}
<ha-md-menu-item .clickAction=${this._enableSelected}>
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.enable_selected.button"
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._disableSelected}>
<ha-svg-icon
slot="start"
.path=${mdiToggleSwitchOffOutline}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.disable_selected.button"
)}
</div>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${renderDisableableMenuItem(
this._unhideSelected,
mdiEye,
"ui.panel.config.entities.picker.unhide_selected.button",
"unhide-selected-disabled"
)}
${renderDisableableMenuItem(
this._hideSelected,
mdiEyeOff,
"ui.panel.config.entities.picker.hide_selected.button",
"hide-selected-disabled"
)}
<ha-md-menu-item .clickAction=${this._unhideSelected}>
<ha-svg-icon
slot="start"
.path=${mdiEye}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.unhide_selected.button"
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._hideSelected}>
<ha-svg-icon
slot="start"
.path=${mdiEyeOff}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.hide_selected.button"
)}
</div>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${
isComponentLoaded(this.hass, "recorder")
? html`
<ha-md-menu-item .clickAction=${this._enableRecordingSelected}>
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.enable_recording_selected.button"
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._disableRecordingSelected}>
<ha-svg-icon
slot="start"
.path=${mdiToggleSwitchOffOutline}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.disable_recording_selected.button"
)}
</div>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
`
: ""
}
${renderDisableableMenuItem(
this._restoreEntityIdSelected,
mdiRestore,
"ui.panel.config.entities.picker.restore_entity_id_selected.button",
"restore-selected-disabled"
)}
<ha-md-menu-item .clickAction=${this._restoreEntityIdSelected}>
<ha-svg-icon
slot="start"
.path=${mdiRestore}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.restore_entity_id_selected.button"
)}
</div>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${renderDisableableMenuItem(
this._removeSelected,
mdiDelete,
"ui.panel.config.entities.picker.delete_selected.button",
"delete-selected-disabled",
true // warning style
)}
<ha-md-menu-item .clickAction=${this._removeSelected} class="warning">
<ha-svg-icon
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.delete_selected.button"
)}
</div>
</ha-md-menu-item>
</ha-md-button-menu>
${
@@ -1282,12 +1108,6 @@ ${
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
// Fetch recording data if the column is visible
if (!this._activeHiddenColumns?.includes("recorded")) {
this._fetchRecordingData();
}
if (Object.keys(this._filters).length) {
return;
}
@@ -1343,14 +1163,6 @@ ${
changedProps.has("_entities") ||
changedProps.has("_entitySources")
) {
// Re-fetch recording data when entities change
if (
changedProps.has("_entities") &&
this._recordingEntities &&
!this._activeHiddenColumns?.includes("recorded")
) {
this._fetchRecordingData();
}
const stateEntities: StateEntity[] = [];
const regEntityIds = new Set(
this._entities.map((entity) => entity.entity_id)
@@ -1378,7 +1190,7 @@ ${
device_id: null,
icon: null,
readonly: true,
selectable: true,
selectable: false,
entity_category: null,
has_entity_name: false,
options: null,
@@ -1528,53 +1340,6 @@ ${
this._clearSelection();
};
private _setRecordingSelected = async (enable: boolean) => {
if (!isComponentLoaded(this.hass, "recorder")) {
return;
}
const action = enable ? "enable" : "disable";
showConfirmationDialog(this, {
title: this.hass.localize(
`ui.panel.config.entities.picker.${action}_recording_selected.confirm_title`,
{ number: this._selected.length }
),
text: this.hass.localize(
`ui.panel.config.entities.picker.${action}_recording_selected.confirm_text`
),
confirmText: this.hass.localize(`ui.common.${action}`),
dismissText: this.hass.localize("ui.common.cancel"),
confirm: async () => {
try {
await setEntityRecordingOptions(
this.hass,
this._selected,
enable ? null : "user" // null = enabled, "user" = disabled by user
);
// Re-fetch recording data to update the table
if (this._recordingEntities) {
await this._fetchRecordingData();
}
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.common.multiselect.failed",
{
number: this._selected.length,
}
),
text: err.message,
});
}
},
});
};
private _enableRecordingSelected = () => this._setRecordingSelected(true);
private _disableRecordingSelected = () => this._setRecordingSelected(false);
private async _handleBulkLabel(ev) {
const label = ev.currentTarget.value;
const action = ev.currentTarget.action;
@@ -1733,8 +1498,7 @@ ${rejected
this._filters,
this._filteredItems,
this._entries,
this._labels,
this._recordingEntities
this._labels
);
if (
filteredDomains.size === 1 &&
@@ -1764,30 +1528,9 @@ ${rejected
this._activeCollapsed = ev.detail.value;
}
private async _fetchRecordingData() {
if (!isComponentLoaded(this.hass, "recorder")) {
return;
}
try {
this._recordingEntities = await getEntityRecordingList(this.hass);
} catch (err: any) {
// eslint-disable-next-line no-console
console.error("Failed to fetch recording data:", err);
this._recordingEntities = undefined;
}
}
private _handleColumnsChanged(ev: CustomEvent) {
this._activeColumnOrder = ev.detail.columnOrder;
this._activeHiddenColumns = ev.detail.hiddenColumns;
if (
!this._activeHiddenColumns?.includes("recorded") &&
!this._recordingEntities
) {
this._fetchRecordingData();
}
}
static get styles(): CSSResultGroup {

View File

@@ -1,38 +0,0 @@
import type { HaSwitch } from "../../../components/ha-switch";
import { setEntityRecordingOptions } from "../../../data/recorder";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
export interface RecordingChangeParams {
hass: HomeAssistant;
entityId: string;
checkbox: HaSwitch;
onSuccess?: (recordingDisabled: boolean) => void;
onError?: () => void;
}
export const handleRecordingChange = async (params: RecordingChangeParams) => {
const { hass, entityId, checkbox, onSuccess, onError } = params;
const newRecordingDisabled = !checkbox.checked;
try {
await setEntityRecordingOptions(
hass,
[entityId],
newRecordingDisabled ? "user" : null
);
if (onSuccess) {
onSuccess(newRecordingDisabled);
}
} catch (err: any) {
showAlertDialog(checkbox, {
text: err.message,
});
checkbox.checked = !checkbox.checked;
if (onError) {
onError();
}
}
};

View File

@@ -38,6 +38,7 @@ export class HaConfigSection extends LitElement {
:host {
display: block;
}
.content {
padding: 28px 20px 0;
max-width: 1040px;

View File

@@ -912,6 +912,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
max-width: 1000px;
padding: 32px;
}
:host([narrow]) .container {
padding: 16px;
}
.container > * {
flex-grow: 1;
}
@@ -952,9 +955,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
.card-content {
padding: 16px 0 8px;
}
:host([narrow]) .container {
padding: 16px;
}
.card-header {
padding-bottom: 0;
}

View File

@@ -18,6 +18,7 @@ import type { HomeAssistant, Route } from "../../../types";
import "./error-log-card";
import "./system-log-card";
import type { SystemLogCard } from "./system-log-card";
import { stringCompare } from "../../../common/string/compare";
const logProviders: LogProvider[] = [
{
@@ -208,15 +209,17 @@ export class HaConfigLogs extends LitElement {
private async _getInstalledAddons() {
try {
const addonsInfo = await fetchHassioAddonsInfo(this.hass);
this._logProviders = [
...this._logProviders,
...addonsInfo.addons
.filter((addon) => addon.version)
.map((addon) => ({
key: addon.slug,
name: addon.name,
})),
];
const sortedAddons = addonsInfo.addons
.filter((addon) => addon.version)
.map((addon) => ({
key: addon.slug,
name: addon.name,
}))
.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
this._logProviders = [...this._logProviders, ...sortedAddons];
} catch (_err) {
// Ignore, nothing the user can do anyway
}

View File

@@ -66,6 +66,8 @@ export class HaBlueprintScriptEditor extends HaBlueprintGenericEditor {
}
ha-fab {
position: fixed;
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
right: calc(16px + var(--safe-area-inset-right, 0px));
}
`,
];

View File

@@ -1139,7 +1139,7 @@ export class HaScriptEditor extends SubscribeMixin(
transition: bottom 0.3s;
}
ha-fab.dirty {
bottom: 16px;
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
}
li[role="separator"] {
border-bottom-color: var(--divider-color);

View File

@@ -147,10 +147,19 @@ export class HaPanelCustom extends ReactiveElement {
<style>
iframe {
border: 0;
width: 100%;
height: 100%;
width: calc(100% - var(--safe-area-inset-right, 0px));
height: calc(100% - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px));
display: block;
background-color: var(--primary-background-color);
margin-top: var(--safe-area-inset-top);
margin-bottom: var(--safe-area-inset-bottom);
margin-right: var(--safe-area-inset-right);
}
@media (max-width: 870px) {
iframe {
width: calc(100% - var(--safe-area-inset-left, 0px) - var(--safe-area-inset-right, 0px));
margin-left: var(--safe-area-inset-left);
}
}
</style>
<iframe ${titleAttr}></iframe>`.trim();

View File

@@ -496,7 +496,12 @@ class PanelEnergy extends LitElement {
border-bottom: var(--app-header-border-bottom, none);
position: fixed;
top: 0;
width: var(--mdc-top-app-bar-width, 100%);
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-right,
0px
)
);
padding-top: var(--safe-area-inset-top);
z-index: 4;
transition: box-shadow 200ms linear;
@@ -504,6 +509,17 @@ class PanelEnergy extends LitElement {
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
backdrop-filter: var(--app-header-backdrop-filter, none);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .header {
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-left,
0px
) - var(--safe-area-inset-right, 0px)
);
padding-left: var(--safe-area-inset-left);
}
:host([scrolled]) .header {
box-shadow: var(
@@ -523,10 +539,8 @@ class PanelEnergy extends LitElement {
font-weight: var(--ha-font-weight-normal);
box-sizing: border-box;
}
@media (max-width: 599px) {
.toolbar {
padding: 0 4px;
}
:host([narrow]) .toolbar {
padding: 0 4px;
}
.main-title {
margin: var(--margin-title);
@@ -539,12 +553,14 @@ class PanelEnergy extends LitElement {
min-height: 100vh;
box-sizing: border-box;
padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
padding-inline-start: var(--safe-area-inset-left);
padding-inline-end: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
}
:host([narrow]) hui-view-container {
padding-left: var(--safe-area-inset-left);
padding-inline-start: var(--safe-area-inset-left);
}
hui-view {
flex: 1 1 100%;
max-width: 100%;

View File

@@ -120,7 +120,7 @@ class HaPanelHistory extends LitElement {
protected render() {
const entitiesSelected = this._getEntityIds().length > 0;
return html`
<ha-top-app-bar-fixed>
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._showBack
? html`
<ha-icon-button-arrow-prev
@@ -620,7 +620,6 @@ class HaPanelHistory extends LitElement {
.content {
padding: 0 16px 16px;
padding-bottom: max(var(--safe-area-inset-bottom), 16px);
}
:host([virtualize]) {

View File

@@ -25,6 +25,8 @@ import "./ha-logbook";
import { storage } from "../../common/decorators/storage";
import { ensureArray } from "../../common/array/ensure-array";
import { resolveEntityIDs } from "../../data/selector";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import type { HaEntityPickerEntityFilterFunc } from "../../components/entity/ha-entity-picker";
@customElement("ha-panel-logbook")
export class HaPanelLogbook extends LitElement {
@@ -47,6 +49,8 @@ export class HaPanelLogbook extends LitElement {
})
private _targetPickerValue: HassServiceTarget = {};
@state() private _sensorNumericDeviceClasses?: string[] = [];
public constructor() {
super();
@@ -65,7 +69,7 @@ export class HaPanelLogbook extends LitElement {
protected render() {
return html`
<ha-top-app-bar-fixed>
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._showBack
? html`
<ha-icon-button-arrow-prev
@@ -100,7 +104,7 @@ export class HaPanelLogbook extends LitElement {
<ha-target-picker
.hass=${this.hass}
.entityFilter=${filterLogbookCompatibleEntities}
.entityFilter=${this._filterFunc}
.value=${this._targetPickerValue}
add-on-top
@value-changed=${this._targetsChanged}
@@ -118,6 +122,9 @@ export class HaPanelLogbook extends LitElement {
`;
}
private _filterFunc: HaEntityPickerEntityFilterFunc = (entity) =>
filterLogbookCompatibleEntities(entity, this._sensorNumericDeviceClasses);
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
@@ -128,9 +135,15 @@ export class HaPanelLogbook extends LitElement {
this._applyURLParams();
}
private async _loadNumericDeviceClasses() {
const deviceClasses = await getSensorNumericDeviceClasses(this.hass);
this._sensorNumericDeviceClasses = deviceClasses.numeric_device_classes;
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
this._loadNumericDeviceClasses();
const searchParams = extractSearchParamsObject();
if (searchParams.back === "1" && history.length > 1) {
@@ -290,11 +303,23 @@ export class HaPanelLogbook extends LitElement {
haStyle,
css`
ha-logbook {
height: calc(100vh - 136px);
height: calc(
100vh -
168px - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
0px
)
);
}
:host([narrow]) ha-logbook {
height: calc(100vh - 198px);
height: calc(
100vh -
250px - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
0px
)
);
}
ha-date-range-picker {

View File

@@ -419,7 +419,13 @@ class HuiEnergySankeyCard
};
deviceNodes.forEach((deviceNode) => {
const entity = this.hass.states[deviceNode.id];
const { area, floor } = getEntityContext(entity, this.hass);
const { area, floor } = getEntityContext(
entity,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
if (area) {
if (area.area_id in areas) {
areas[area.area_id].value += deviceNode.value;
@@ -457,6 +463,9 @@ class HuiEnergySankeyCard
return { areas, floors };
}
/**
* Organizes device nodes into hierarchical sections based on parent-child relationships.
*/
protected _getDeviceSections(
parentLinks: Record<string, string>,
deviceNodes: Node[]
@@ -465,20 +474,34 @@ class HuiEnergySankeyCard
const childSection: Node[] = [];
const parentIds = Object.values(parentLinks);
const remainingLinks: typeof parentLinks = {};
deviceNodes.forEach((deviceNode) => {
if (parentIds.includes(deviceNode.id)) {
const isChild = deviceNode.id in parentLinks;
const isParent = parentIds.includes(deviceNode.id);
if (isParent && !isChild) {
// Top-level parents (have children but no parents themselves)
parentSection.push(deviceNode);
remainingLinks[deviceNode.id] = parentLinks[deviceNode.id];
} else {
childSection.push(deviceNode);
}
});
// Filter out links where parent is already in current parent section
Object.entries(parentLinks).forEach(([child, parent]) => {
if (!parentSection.some((node) => node.id === parent)) {
remainingLinks[child] = parent;
}
});
if (parentSection.length > 0) {
// Recursively process child section with remaining links
return [
...this._getDeviceSections(remainingLinks, parentSection),
childSection,
parentSection,
...this._getDeviceSections(remainingLinks, childSection),
];
}
// Base case: no more parent-child relationships to process
return [deviceNodes];
}

View File

@@ -275,7 +275,7 @@ export class HuiCard extends ReactiveElement {
);
}
private _updateVisibility(forceVisible?: boolean) {
private _updateVisibility(ignoreConditions?: boolean) {
if (!this._element || !this.hass) {
return;
}
@@ -285,9 +285,18 @@ export class HuiCard extends ReactiveElement {
return;
}
if (this.preview) {
this._setElementVisibility(true);
return;
}
if (this.config?.disabled) {
this._setElementVisibility(false);
return;
}
const visible =
forceVisible ||
this.preview ||
ignoreConditions ||
!this.config?.visibility ||
checkConditionsMet(this.config.visibility, this.hass);
this._setElementVisibility(visible);

View File

@@ -325,11 +325,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
></ha-state-icon>
${renderTileBadge(stateObj, this.hass)}
</ha-tile-icon>
<ha-tile-info
id="info"
.primary=${name}
.secondary=${stateDisplay}
></ha-tile-info>
<ha-tile-info id="info">
<span slot="primary" class="primary">${name}</span>
${stateDisplay
? html`<span slot="secondary">${stateDisplay}</span>`
: nothing}
</ha-tile-info>
</div>
${features.length > 0
? html`

View File

@@ -5,11 +5,7 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeEntityName } from "../../../../common/entity/compute_entity_name";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/data-table/ha-data-table";
@@ -66,11 +62,9 @@ export class HuiEntityPickerTable extends LitElement {
(entity) => {
const stateObj = this.hass.states[entity];
const { area, device } = getEntityContext(stateObj, this.hass);
const entityName = computeEntityName(stateObj, this.hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const entityName = this.hass.formatEntityName(stateObj, "entity");
const deviceName = this.hass.formatEntityName(stateObj, "device");
const areaName = this.hass.formatEntityName(stateObj, "area");
const name = [deviceName, entityName].filter(Boolean).join(" ");
const domain = computeDomain(entity);

View File

@@ -22,6 +22,8 @@ import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { DEFAULT_HOURS_TO_SHOW } from "../../cards/hui-logbook-card";
import { targetStruct } from "../../../../data/script";
import type { HaEntityPickerEntityFilterFunc } from "../../../../components/entity/ha-entity-picker";
import { getSensorNumericDeviceClasses } from "../../../../data/sensor";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
@@ -59,6 +61,8 @@ export class HuiLogbookCardEditor
@state() private _config?: LogbookCardConfig;
@state() private _sensorNumericDeviceClasses?: string[];
public setConfig(config: LogbookCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
@@ -80,6 +84,20 @@ export class HuiLogbookCardEditor
);
}
private async _loadNumericDeviceClasses(hass: HomeAssistant) {
// ensures that the _load function is not called a second time
// if another updated occurs before the async function returns
this._sensorNumericDeviceClasses = [];
const deviceClasses = await getSensorNumericDeviceClasses(hass);
this._sensorNumericDeviceClasses = deviceClasses.numeric_device_classes;
}
protected updated() {
if (this.hass && !this._sensorNumericDeviceClasses) {
this._loadNumericDeviceClasses(this.hass);
}
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
@@ -96,7 +114,7 @@ export class HuiLogbookCardEditor
<ha-target-picker
.hass=${this.hass}
.entityFilter=${filterLogbookCompatibleEntities}
.entityFilter=${this._filterFunc}
.value=${this._targetPicker}
add-on-top
@value-changed=${this._entitiesChanged}
@@ -104,6 +122,9 @@ export class HuiLogbookCardEditor
`;
}
private _filterFunc: HaEntityPickerEntityFilterFunc = (entity) =>
filterLogbookCompatibleEntities(entity, this._sensorNumericDeviceClasses);
private _entitiesChanged(ev: CustomEvent): void {
this._config = { ...this._config!, target: ev.detail.value };
fireEvent(this, "config-changed", { config: this._config });

View File

@@ -157,6 +157,7 @@ export class LovelacePanel extends LitElement {
if (panelState === "yaml-editor") {
return html`
<hui-editor
.narrow=${this.narrow}
.hass=${this.hass}
.lovelace=${this.lovelace}
.closeEditor=${this._closeEditor}

View File

@@ -36,6 +36,8 @@ const strategyStruct = type({
@customElement("hui-editor")
class LovelaceFullConfigEditor extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public lovelace?: Lovelace;
@@ -48,7 +50,7 @@ class LovelaceFullConfigEditor extends LitElement {
protected render(): TemplateResult | undefined {
return html`
<ha-top-app-bar-fixed>
<ha-top-app-bar-fixed .narrow=${this.narrow}>
<ha-icon-button
slot="navigationIcon"
.path=${mdiClose}

View File

@@ -230,10 +230,10 @@ class HUIRoot extends LitElement {
},
{
icon: mdiSofa,
key: "ui.panel.lovelace.menu.add_area",
key: "ui.panel.lovelace.menu.create_area",
visible: true,
action: this._addArea,
overflowAction: this._handleAddArea,
action: this._createArea,
overflowAction: this._handleCreateArea,
},
{
icon: mdiAccount,
@@ -366,6 +366,7 @@ class HUIRoot extends LitElement {
}
showListItemsDialog(this, {
title: title,
mode: this.narrow ? "bottom-sheet" : "dialog",
items: i.subItems!.map((si) => ({
iconPath: si.icon,
label: this.hass!.localize(si.key),
@@ -837,14 +838,14 @@ class HUIRoot extends LitElement {
showNewAutomationDialog(this, { mode: "automation" });
};
private _handleAddArea(ev: CustomEvent<RequestSelectedDetail>): void {
private _handleCreateArea(ev: CustomEvent<RequestSelectedDetail>): void {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
this._addArea();
this._createArea();
}
private _addArea = async () => {
private _createArea = async () => {
await this.hass.loadFragmentTranslation("config");
showAreaRegistryDetailDialog(this, {
createEntry: async (values) => {
@@ -854,13 +855,15 @@ class HUIRoot extends LitElement {
}
showToast(this, {
message: this.hass.localize(
"ui.panel.lovelace.menu.add_area_success"
"ui.panel.lovelace.menu.create_area_success"
),
action: {
action: () => {
navigate(`/config/areas/area/${area.area_id}`);
},
text: this.hass.localize("ui.panel.lovelace.menu.add_area_action"),
text: this.hass.localize(
"ui.panel.lovelace.menu.create_area_action"
),
},
});
},
@@ -1214,20 +1217,28 @@ class HUIRoot extends LitElement {
border-bottom: var(--app-header-border-bottom, none);
position: fixed;
top: 0;
width: var(
--mdc-top-app-bar-width,
calc(
100% - var(--safe-area-inset-left) - var(--safe-area-inset-right)
)
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-right,
0px
)
);
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
backdrop-filter: var(--app-header-backdrop-filter, none);
padding-top: var(--safe-area-inset-top);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
z-index: 4;
transition: box-shadow 200ms linear;
}
.narrow .header {
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-left,
0px
) - var(--safe-area-inset-right, 0px)
);
padding-left: var(--safe-area-inset-left);
}
:host([scrolled]) .header {
box-shadow: var(
--mdc-top-app-bar-fixed-box-shadow,
@@ -1249,10 +1260,8 @@ class HUIRoot extends LitElement {
font-weight: var(--ha-font-weight-normal);
box-sizing: border-box;
}
@media (max-width: 599px) {
.toolbar {
padding: 0 4px;
}
.narrow .toolbar {
padding: 0 4px;
}
.main-title {
margin: var(--margin-title);
@@ -1387,12 +1396,14 @@ class HUIRoot extends LitElement {
min-height: 100vh;
box-sizing: border-box;
padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
padding-inline-start: var(--safe-area-inset-left);
padding-inline-end: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
}
.narrow hui-view-container {
padding-left: var(--safe-area-inset-left);
padding-inline-start: var(--safe-area-inset-left);
}
hui-view-container > * {
flex: 1 1 100%;
max-width: 100%;

View File

@@ -222,16 +222,32 @@ export class HuiSection extends ReactiveElement {
}
}
private _updateElement(forceVisible?: boolean) {
private _updateElement(ignoreConditions?: boolean) {
if (!this._layoutElement) {
return;
}
if (this.preview) {
this._setElementVisibility(true);
return;
}
if (this.config.disabled) {
this._setElementVisibility(false);
return;
}
const visible =
forceVisible ||
this.preview ||
ignoreConditions ||
!this.config.visibility ||
checkConditionsMet(this.config.visibility, this.hass);
this._setElementVisibility(visible);
}
private _setElementVisibility(visible: boolean) {
if (!this._layoutElement) return;
if (this.hidden !== !visible) {
this.style.setProperty("display", visible ? "" : "none");
this.toggleAttribute("hidden", !visible);

View File

@@ -1,7 +1,6 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeEntityName } from "../../../../common/entity/compute_entity_name";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { generateEntityFilter } from "../../../../common/entity/entity_filter";
import { clamp } from "../../../../common/number/clamp";
@@ -177,7 +176,13 @@ export class HomeAreaViewStrategy extends ReactiveElement {
for (const entityId of otherEntities) {
const stateObj = hass.states[entityId];
if (!stateObj) continue;
const { device } = getEntityContext(stateObj, hass);
const { device } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
if (!device) {
unassignedEntities.push(entityId);
continue;
@@ -268,8 +273,8 @@ export class HomeAreaViewStrategy extends ReactiveElement {
return {
...computeTileCard(e),
name:
computeEntityName(stateObj, hass) ||
(device ? computeDeviceName(device) : ""),
hass.formatEntityName(stateObj, "entity") ||
hass.formatEntityName(stateObj, "device"),
};
}),
],

View File

@@ -21,7 +21,7 @@ class HaPanelMap extends LitElement {
protected render() {
return html`
<ha-top-app-bar-fixed>
<ha-top-app-bar-fixed .narrow=${this.narrow}>
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}

View File

@@ -560,7 +560,10 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
var(--card-background-color, white)
);
border-top: 1px solid var(--divider-color);
padding-bottom: var(--safe-area-inset-bottom);
margin-right: var(--safe-area-inset-right);
}
:host([narrow]) {
margin-left: var(--safe-area-inset-left);
}
mwc-linear-progress {

View File

@@ -92,7 +92,7 @@ class PanelMediaBrowser extends LitElement {
protected render(): TemplateResult {
return html`
<ha-top-app-bar-fixed>
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._navigateIds.length > 1
? html`
<ha-icon-button-arrow-prev
@@ -349,19 +349,48 @@ class PanelMediaBrowser extends LitElement {
}
ha-media-player-browse {
height: calc(100vh - (100px + var(--header-height)));
height: calc(
100vh -
(
100px + var(--header-height, 0px) +
var(--safe-area-inset-top, 0px) +
var(--safe-area-inset-bottom, 0px)
)
);
}
:host([narrow]) ha-media-player-browse {
height: calc(100vh - (57px + var(--header-height)));
height: calc(
100vh -
(
68px + var(--header-height, 0px) +
var(--safe-area-inset-top, 0px) +
var(--safe-area-inset-bottom, 0px)
)
);
}
.selected_menu_item {
color: var(--primary-color);
}
ha-bar-media-player {
position: fixed;
width: var(--mdc-top-app-bar-width, 100%);
bottom: var(--safe-area-inset-bottom, 0px);
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-right,
0px
)
);
}
:host([narrow]) ha-bar-media-player {
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-left,
0px
) - var(--safe-area-inset-right, 0px)
);
}
`,
];

View File

@@ -1,4 +1,4 @@
import { formatInTimeZone, toDate } from "date-fns-tz";
import { TZDate } from "@date-fns/tz";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -239,7 +239,8 @@ class DialogTodoItemEditor extends LitElement {
// Formats a date in specified timezone, or defaulting to browser display timezone
private _formatDate(date: Date, timeZone: string = this._timeZone!): string {
return formatInTimeZone(date, timeZone, "yyyy-MM-dd");
const tzDate = new TZDate(date, timeZone);
return tzDate.toISOString().split("T")[0]; // Get YYYY-MM-DD format
}
// Formats a time in specified timezone, or defaulting to browser display timezone
@@ -247,14 +248,15 @@ class DialogTodoItemEditor extends LitElement {
date: Date,
timeZone: string = this._timeZone!
): string | undefined {
return this._hasTime
? formatInTimeZone(date, timeZone, "HH:mm:ss")
: undefined; // 24 hr
if (!this._hasTime) return undefined;
const tzDate = new TZDate(date, timeZone);
return tzDate.toISOString().split("T")[1].split(".")[0]; // Get HH:mm:ss format
}
// Parse a date in the browser timezone
private _parseDate(dateStr: string): Date {
return toDate(dateStr, { timeZone: this._timeZone! });
const tzDate = new TZDate(dateStr, this._timeZone!);
return new Date(tzDate.getTime());
}
private _checkedCanged(ev) {

View File

@@ -165,7 +165,11 @@ class PanelTodo extends LitElement {
</ha-list-item> `
);
return html`
<ha-two-pane-top-app-bar-fixed .pane=${showPane} footer>
<ha-two-pane-top-app-bar-fixed
.pane=${showPane}
footer
.narrow=${this.narrow}
>
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
@@ -403,9 +407,9 @@ class PanelTodo extends LitElement {
}
ha-fab {
position: fixed;
right: 16px;
bottom: 16px;
inset-inline-end: 16px;
right: calc(16px + var(--safe-area-inset-right, 0px));
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
inset-inline-end: calc(16px + var(--safe-area-inset-right, 0px));
inset-inline-start: initial;
}
`,

View File

@@ -33,6 +33,7 @@ import { fetchWithAuth } from "../util/fetch-with-auth";
import { getState } from "../util/ha-pref-storage";
import hassCallApi, { hassCallApiRaw } from "../util/hass-call-api";
import type { HassBaseEl } from "./hass-base-mixin";
import { computeStateName } from "../common/entity/compute_state_name";
export const connectionMixin = <T extends Constructor<HassBaseEl>>(
superClass: T
@@ -210,6 +211,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
value != null ? value : (stateObj.attributes[attribute] ?? ""),
...getState(),
...this._pendingHass,
formatEntityName: (stateObj) => computeStateName(stateObj),
};
this.hassConnected();

View File

@@ -8,7 +8,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) => {
class StateDisplayMixin extends superClass {
protected hassConnected() {
super.hassConnected();
this._updateStateDisplay();
this._updateFormatFunctions();
}
protected willUpdate(changedProps) {
@@ -25,13 +25,16 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) => {
this.hass.localize !== oldHass.localize ||
this.hass.locale !== oldHass.locale ||
this.hass.config !== oldHass.config ||
this.hass.entities !== oldHass.entities)
this.hass.entities !== oldHass.entities ||
this.hass.devices !== oldHass.devices ||
this.hass.areas !== oldHass.areas ||
this.hass.floors !== oldHass.floors)
) {
this._updateStateDisplay();
this._updateFormatFunctions();
}
}
private _updateStateDisplay = async () => {
private _updateFormatFunctions = async () => {
if (!this.hass || !this.hass.config) {
return;
}
@@ -52,17 +55,22 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) => {
formatEntityState,
formatEntityAttributeName,
formatEntityAttributeValue,
formatEntityName,
} = await computeFormatFunctions(
this.hass.localize,
this.hass.locale,
this.hass.config,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors,
sensorNumericDeviceClasses
);
this._updateHass({
formatEntityState,
formatEntityAttributeName,
formatEntityAttributeValue,
formatEntityName,
});
};
}

View File

@@ -1512,7 +1512,7 @@
"settings": "Settings",
"control": "Control",
"related": "Related",
"no_unique_id": "This entity (''{entity_id}'') does not have a unique ID, therefore only limited settings can be managed from the UI. See the {faq_link} for more detail.",
"no_unique_id": "This entity (''{entity_id}'') does not have a unique ID, therefore its settings cannot be managed from the UI. See the {faq_link} for more detail.",
"faq": "documentation",
"editor": {
"name": "Name",
@@ -1605,10 +1605,6 @@
"enabled_delay_confirm": "The enabled entities will be added to Home Assistant in {delay} seconds",
"enabled_restart_confirm": "Restart Home Assistant to finish enabling the entities",
"hidden_explanation": "Hidden entities will not be included in auto-populated dashboards or when their area, device or label is referenced. Their history is still tracked and you can still interact with them with actions.",
"record_label": "Record",
"record_description": "Control whether this entity's state history is recorded by Home Assistant Recorder.",
"error_loading_recording_settings": "Error loading recording settings",
"error_updating_recording_settings": "Error updating recording settings",
"delete": "Delete",
"confirm_delete": "Are you sure you want to delete this entity?",
"update": "Update",
@@ -2465,7 +2461,7 @@
"local_backup_location": {
"title": "Change default local backup location",
"description": "Change the default location where local backups are stored on your Home Assistant instance.",
"note": "This location will be used when you create a backup using the supervisor actions in an automation for example.",
"note": "This location will be used when you create a backup using the Supervisor actions in an automation for example.",
"options": {
"default_backup_mount": {
"name": "Default location"
@@ -2897,8 +2893,8 @@
"description": "Creates a backup of your add-on and its data. That way you can keep around the previous version of the add-on, so you can always roll back to it if needed.",
"local_only": "This backup is only saved on this system.",
"retention_description": "Prevent your system from filling up with old versions.",
"error_load": "Error loading supervisor update config: {error}",
"error_save": "Error saving supervisor update config: {error}"
"error_load": "Error loading Supervisor update config: {error}",
"error_save": "Error saving Supervisor update config: {error}"
}
},
"details": {
@@ -5310,12 +5306,7 @@
"domain": "Domain",
"availability": "Availability",
"visibility": "Visibility",
"enabled": "Enabled",
"recorded": "Recorded"
},
"recorded": {
"enabled": "Recording enabled",
"disabled": "Recording disabled"
"enabled": "Enabled"
},
"selected": "{number} selected",
"enable_selected": {
@@ -5340,16 +5331,6 @@
"confirm_text": "Are you sure you want to delete the entities?\n\nRemove them from your dashboard and automations if they include these entities.",
"confirm_partly_text": "You can only delete {deletable} of the {selected} entities. The others require the integration to stop providing them, and sometimes a Home Assistant restart is needed. Are you sure you want to delete the deletable entities?\n\nRemove them from your dashboard and automations if they include these entities."
},
"enable_recording_selected": {
"button": "Enable recording for selected",
"confirm_title": "Do you want to enable recording for {number} {number, plural,\n one {entity}\n other {entities}\n}?",
"confirm_text": "This will resume recording their state history in the Recorder database."
},
"disable_recording_selected": {
"button": "Disable recording for selected",
"confirm_title": "Do you want to disable recording for {number} {number, plural,\n one {entity}\n other {entities}\n}?",
"confirm_text": "This will stop recording their state history in the Recorder database."
},
"hide_selected": {
"button": "Hide selected",
"confirm_title": "Do you want to hide {number} {number, plural,\n one {entity}\n other {entities}\n}?",
@@ -5357,8 +5338,7 @@
},
"unhide_selected": {
"button": "Unhide selected"
},
"non_unique_id_selected": "Select only entities with unique IDs to enable this operation"
}
}
},
"person": {
@@ -5570,7 +5550,7 @@
},
"custom_integration": "Custom integration",
"legacy_integration": "Legacy integration",
"custom_overwrites_core": "Custom integration that replaces a core component",
"custom_overwrites_core": "Custom integration that replaces a Core component",
"depends_on_cloud": "Requires Internet",
"yaml_only": "This integration cannot be set up from the UI",
"no_config_flow": "This integration was not set up from the UI",
@@ -7079,9 +7059,9 @@
"add": "Add to Home Assistant",
"add_device": "Add device",
"create_automation": "Create automation",
"add_area": "Add area",
"add_area_success": "Area added",
"add_area_action": "View area",
"create_area": "Create area",
"create_area_success": "Area created",
"create_area_action": "View area",
"add_person_success": "Person added",
"add_person_action": "View persons",
"add_person": "Add person"
@@ -8909,13 +8889,13 @@
"title": "Entity is not recorded",
"info_text_1": "State changes of ''{name}'' ({statistic_id}) are not recorded, therefore, we cannot track long term statistics for it.",
"info_text_2": "You probably excluded this entity, or have just included some entities.",
"info_text_3_link": "See the recorder documentation for more information."
"info_text_3_link": "See the Recorder documentation for more information."
},
"entity_no_longer_recorded": {
"title": "Entity is no longer recorded",
"info_text_1": "We have generated statistics for ''{name}'' ({statistic_id}) in the past, but state changes of this entity are no longer recorded, therefore, we cannot track long term statistics for it anymore.",
"info_text_2": "You probably excluded this entity, or have just included some entities.",
"info_text_3_link": "See the recorder documentation for more information.",
"info_text_3_link": "See the Recorder documentation for more information.",
"info_text_4": "If you no longer wish to keep the long term statistics recorded in the past, you may delete them now."
},
"state_class_removed": {
@@ -9646,9 +9626,9 @@
"update_supervisor": "Update the Supervisor",
"channel": "Channel",
"leave_beta_action": "Leave beta channel",
"leave_beta_description": "Get stable updates for Home Assistant, Supervisor and host",
"leave_beta_description": "Get stable updates for Home Assistant OS, Core, Supervisor and Frontend",
"join_beta_action": "Join beta channel",
"join_beta_description": "Get beta updates for Home Assistant (RCs), Supervisor and host",
"join_beta_description": "Get beta updates for Home Assistant OS, Core, Supervisor and Frontend",
"share_diagnostics": "Share diagnostics",
"share_diagnostics_description": "Share crash reports and diagnostic information.",
"reload_supervisor": "Reload Supervisor",
@@ -9764,7 +9744,7 @@
"remote_download_text": "You are accessing Home Assistant via remote access. Downloading backups over the Nabu Casa URL will take some time. If you are at home, cancel this dialog and enter your local URL, such as 'http://homeassistant.local:8123'",
"restore_start_failed": "Failed to start restore. Unknown error.",
"no_backup_found": "No backup found.",
"restore_no_home_assistant": "Backup does not contain Home Assistant data. To restore Home Assistant you need a backup of Home Assistant core.",
"restore_no_home_assistant": "Backup does not contain Home Assistant data. To restore Home Assistant you need a backup of Home Assistant Core.",
"unnamed_backup": "Unnamed backup"
},
"dialog": {

View File

@@ -9,6 +9,7 @@ import type {
HassServiceTarget,
MessageBase,
} from "home-assistant-js-websocket";
import type { EntityNameType } from "./common/translations/entity-state";
import type { LocalizeFunc } from "./common/translations/localize";
import type { AreaRegistryEntry } from "./data/area_registry";
import type { DeviceRegistryEntry } from "./data/device_registry";
@@ -285,6 +286,11 @@ export interface HomeAssistant {
value?: any
): string;
formatEntityAttributeName(stateObj: HassEntity, attribute: string): string;
formatEntityName(
stateObj: HassEntity,
type: EntityNameType | EntityNameType[],
separator?: string
): string;
}
export interface Route {

View File

@@ -6,49 +6,53 @@ import {
} from "../../../src/common/entity/compute_entity_name";
import * as computeStateNameModule from "../../../src/common/entity/compute_state_name";
import * as stripPrefixModule from "../../../src/common/entity/strip_prefix_from_entity_name";
import type { HomeAssistant } from "../../../src/types";
import {
mockEntity,
mockEntityEntry,
mockStateObj,
} from "./context/context-mock";
describe("computeEntityName", () => {
it("returns state name if entity not in registry", () => {
vi.spyOn(computeStateNameModule, "computeStateName").mockReturnValue(
"Kitchen Light"
);
const stateObj = {
const stateObj = mockStateObj({
entity_id: "light.kitchen",
attributes: { friendly_name: "Kitchen Light" },
state: "on",
};
});
const hass = {
entities: {},
devices: {},
states: {
"light.kitchen": stateObj,
},
};
expect(computeEntityName(stateObj as any, hass as any)).toBe(
} as unknown as HomeAssistant;
expect(computeEntityName(stateObj, hass.entities, hass.devices)).toBe(
"Kitchen Light"
);
vi.restoreAllMocks();
});
it("returns entity entry name if present", () => {
const stateObj = {
const stateObj = mockStateObj({
entity_id: "light.kitchen",
attributes: {},
state: "on",
};
});
const hass = {
entities: {
"light.kitchen": {
entity_id: "light.kitchen",
name: "Ceiling Light",
labels: [],
},
},
devices: {},
states: {
"light.kitchen": stateObj,
},
};
expect(computeEntityName(stateObj as any, hass as any)).toBe(
} as unknown as HomeAssistant;
expect(computeEntityName(stateObj, hass.entities, hass.devices)).toBe(
"Ceiling Light"
);
});
@@ -56,11 +60,12 @@ describe("computeEntityName", () => {
describe("computeEntityEntryName", () => {
it("returns entry.name if no device", () => {
const entry = { entity_id: "light.kitchen", name: "Ceiling Light" };
const entry = mockEntity({
entity_id: "light.kitchen",
name: "Ceiling Light",
});
const hass = { devices: {}, states: {} };
expect(computeEntityEntryName(entry as any, hass as any)).toBe(
"Ceiling Light"
);
expect(computeEntityEntryName(entry, hass.devices)).toBe("Ceiling Light");
});
it("returns device-stripped name if device present", () => {
@@ -70,16 +75,16 @@ describe("computeEntityEntryName", () => {
vi.spyOn(stripPrefixModule, "stripPrefixFromEntityName").mockImplementation(
(name, prefix) => name.replace(prefix + " ", "")
);
const entry = {
const entry = mockEntity({
entity_id: "light.kitchen",
name: "Kitchen Light",
device_id: "dev1",
};
});
const hass = {
devices: { dev1: {} },
states: {},
};
expect(computeEntityEntryName(entry as any, hass as any)).toBe("Light");
} as unknown as HomeAssistant;
expect(computeEntityEntryName(entry, hass.devices)).toBe("Light");
vi.restoreAllMocks();
});
@@ -87,16 +92,16 @@ describe("computeEntityEntryName", () => {
vi.spyOn(computeDeviceNameModule, "computeDeviceName").mockReturnValue(
"Kitchen Light"
);
const entry = {
const entry = mockEntity({
entity_id: "light.kitchen",
name: "Kitchen Light",
device_id: "dev1",
};
});
const hass = {
devices: { dev1: {} },
states: {},
};
expect(computeEntityEntryName(entry as any, hass as any)).toBeUndefined();
} as unknown as HomeAssistant;
expect(computeEntityEntryName(entry, hass.devices)).toBeUndefined();
vi.restoreAllMocks();
});
@@ -104,35 +109,36 @@ describe("computeEntityEntryName", () => {
vi.spyOn(computeStateNameModule, "computeStateName").mockReturnValue(
"Fallback Name"
);
const entry = { entity_id: "light.kitchen" };
const entry = mockEntity({ entity_id: "light.kitchen" });
const hass = {
devices: {},
states: {
"light.kitchen": { entity_id: "light.kitchen" },
},
};
expect(computeEntityEntryName(entry as any, hass as any)).toBe(
} as unknown as HomeAssistant;
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
expect(computeEntityEntryName(entry, hass.devices, stateObj)).toBe(
"Fallback Name"
);
vi.restoreAllMocks();
});
it("returns original_name if present", () => {
const entry = { entity_id: "light.kitchen", original_name: "Old Name" };
const entry = mockEntityEntry({
entity_id: "light.kitchen",
original_name: "Old Name",
});
const hass = {
devices: {},
states: {},
};
expect(computeEntityEntryName(entry as any, hass as any)).toBe("Old Name");
} as unknown as HomeAssistant;
expect(computeEntityEntryName(entry, hass.devices)).toBe("Old Name");
});
it("returns undefined if no name, original_name, or device", () => {
const entry = { entity_id: "light.kitchen" };
const entry = mockEntity({ entity_id: "light.kitchen" });
const hass = {
devices: {},
states: {},
};
expect(computeEntityEntryName(entry as any, hass as any)).toBeUndefined();
} as unknown as HomeAssistant;
expect(computeEntityEntryName(entry, hass.devices)).toBeUndefined();
});
it("handles entities with numeric original_name (real bug from issue #25363)", () => {

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