mirror of
https://github.com/home-assistant/frontend.git
synced 2025-09-16 08:29:42 +00:00
Compare commits
23 Commits
recorded_e
...
dev
Author | SHA1 | Date | |
---|---|---|---|
![]() |
98c4e34a23 | ||
![]() |
3d005c8316 | ||
![]() |
af31b5add3 | ||
![]() |
9d02a1d391 | ||
![]() |
98e6f32fe8 | ||
![]() |
2726c6a849 | ||
![]() |
c09ec54c76 | ||
![]() |
9f045538a2 | ||
![]() |
c6c4f91b0e | ||
![]() |
f71d8f4367 | ||
![]() |
68c1a38231 | ||
![]() |
a9796e4216 | ||
![]() |
bf6eefb692 | ||
![]() |
7ec3b08444 | ||
![]() |
f3355671d1 | ||
![]() |
c0e240a3bf | ||
![]() |
00fd4753e4 | ||
![]() |
08ac873e3b | ||
![]() |
d12b8d1b1b | ||
![]() |
977207dde4 | ||
![]() |
87a5f1a315 | ||
![]() |
acab2d5ead | ||
![]() |
046fc00f73 |
8
.github/workflows/cast_deployment.yaml
vendored
8
.github/workflows/cast_deployment.yaml
vendored
@@ -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
|
||||
|
22
.github/workflows/ci.yaml
vendored
22
.github/workflows/ci.yaml
vendored
@@ -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
|
||||
|
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -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
|
||||
|
8
.github/workflows/demo_deployment.yaml
vendored
8
.github/workflows/demo_deployment.yaml
vendored
@@ -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
|
||||
|
4
.github/workflows/design_deployment.yaml
vendored
4
.github/workflows/design_deployment.yaml
vendored
@@ -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
|
||||
|
4
.github/workflows/design_preview.yaml
vendored
4
.github/workflows/design_preview.yaml
vendored
@@ -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
|
||||
|
2
.github/workflows/labeler.yaml
vendored
2
.github/workflows/labeler.yaml
vendored
@@ -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
|
||||
|
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -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"
|
||||
|
10
.github/workflows/nightly.yaml
vendored
10
.github/workflows/nightly.yaml
vendored
@@ -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
|
||||
|
2
.github/workflows/relative-ci.yaml
vendored
2
.github/workflows/relative-ci.yaml
vendored
@@ -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 }}
|
||||
|
2
.github/workflows/release-drafter.yaml
vendored
2
.github/workflows/release-drafter.yaml
vendored
@@ -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 }}
|
||||
|
21
.github/workflows/release.yaml
vendored
21
.github/workflows/release.yaml
vendored
@@ -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
|
||||
|
2
.github/workflows/restrict-task-creation.yml
vendored
2
.github/workflows/restrict-task-creation.yml
vendored
@@ -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;
|
||||
|
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -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
|
||||
|
2
.github/workflows/translations.yaml
vendored
2
.github/workflows/translations.yaml
vendored
@@ -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: |
|
||||
|
10
package.json
10
package.json
@@ -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"
|
||||
},
|
||||
|
@@ -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];
|
||||
};
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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;
|
||||
|
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@@ -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,
|
||||
|
@@ -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);
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
@@ -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");
|
||||
|
@@ -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();
|
||||
}
|
||||
|
271
src/components/ha-resizable-bottom-sheet.ts
Normal file
271
src/components/ha-resizable-bottom-sheet.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -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: {
|
||||
|
@@ -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;
|
||||
|
@@ -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);
|
||||
|
@@ -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}
|
||||
|
112
src/components/media-player/ha-browse-media-manual.ts
Normal file
112
src/components/media-player/ha-browse-media-manual.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
@@ -12,6 +12,8 @@ export interface MediaPlayerBrowseDialogParams {
|
||||
navigateIds?: MediaPlayerItemId[];
|
||||
minimumNavigateLevel?: number;
|
||||
accept?: string[];
|
||||
defaultId?: string;
|
||||
defaultType?: string;
|
||||
}
|
||||
|
||||
export const showMediaBrowserDialog = (
|
||||
|
@@ -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;
|
||||
|
@@ -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>;
|
||||
|
@@ -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;
|
||||
};
|
||||
|
@@ -14,4 +14,5 @@ export interface LovelaceCardConfig {
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
visibility?: Condition[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ import type { LovelaceStrategyConfig } from "./strategy";
|
||||
|
||||
export interface LovelaceBaseSectionConfig {
|
||||
visibility?: Condition[];
|
||||
disabled?: boolean;
|
||||
column_span?: number;
|
||||
row_span?: number;
|
||||
/**
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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://");
|
||||
|
||||
|
@@ -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,
|
||||
});
|
||||
|
@@ -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(", ");
|
||||
}
|
||||
|
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
@@ -11,6 +11,7 @@ interface ListItem {
|
||||
export interface ListItemsDialogParams {
|
||||
title?: string;
|
||||
items: ListItem[];
|
||||
mode?: "dialog" | "bottom-sheet";
|
||||
}
|
||||
|
||||
export const showListItemsDialog = (
|
||||
|
@@ -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,
|
||||
|
@@ -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)
|
||||
|
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
@@ -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]
|
||||
|
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
`,
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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.
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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);
|
||||
|
@@ -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 {
|
||||
|
@@ -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));
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
@@ -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));
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
@@ -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 {
|
||||
|
@@ -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"
|
||||
|
@@ -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 {
|
||||
|
@@ -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(
|
||||
|
@@ -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);
|
||||
|
@@ -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!,
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
@@ -38,6 +38,7 @@ export class HaConfigSection extends LitElement {
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 28px 20px 0;
|
||||
max-width: 1040px;
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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));
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
@@ -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);
|
||||
|
@@ -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();
|
||||
|
@@ -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%;
|
||||
|
@@ -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]) {
|
||||
|
@@ -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 {
|
||||
|
@@ -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];
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
|
@@ -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`
|
||||
|
@@ -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);
|
||||
|
||||
|
@@ -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 });
|
||||
|
@@ -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}
|
||||
|
@@ -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}
|
||||
|
@@ -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%;
|
||||
|
@@ -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);
|
||||
|
@@ -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"),
|
||||
};
|
||||
}),
|
||||
],
|
||||
|
@@ -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}
|
||||
|
@@ -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 {
|
||||
|
@@ -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)
|
||||
);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
@@ -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) {
|
||||
|
@@ -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;
|
||||
}
|
||||
`,
|
||||
|
@@ -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();
|
||||
|
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@@ -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": {
|
||||
|
@@ -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 {
|
||||
|
@@ -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
Reference in New Issue
Block a user