Compare commits

..

26 Commits

Author SHA1 Message Date
Bram Kragten d24a84e8e3 fix(build): make license override check hoisting-robust
The gen-licenses task read the top-level node_modules/type-fest to verify
the pinned 5.7.0 override, but yarn may hoist a different version there when
unrelated dependencies shift the graph (browserstack-node-sdk's subtree pulls
older type-fest versions via global-agent, pushing boxen's 2.19.0 to the root
slot). The production-bundled copy via zxing-wasm is still 5.7.0, so the
generated license file was correct — only the pre-flight check failed.

Resolve the pinned version wherever it is installed (hoisted root or nested
under its consumer) instead of assuming it is hoisted. The version-bump guard
still fails loudly if the pinned version is no longer installed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:50:25 +02:00
Bram Kragten 1af4985714 Merge remote-tracking branch 'origin/dev' into e2e-playwright-tests
# Conflicts:
#	package.json
#	yarn.lock
2026-06-10 10:27:24 +02:00
Bram Kragten dff5767b27 update 2026-06-10 09:48:17 +02:00
Bram Kragten f40ddceadc test(e2e): handle skip return from waitForLocator in all sidebar wait calls 2026-05-27 10:27:42 +02:00
Bram Kragten de9a6d82de test(e2e): skip on both dynamic-import errors and BrowserStack iOS Internal error 2026-05-27 10:27:42 +02:00
Bram Kragten b691ebe336 test(e2e): revert to locator.waitFor with BS iOS Internal error catch-and-skip 2026-05-27 10:27:42 +02:00
Bram Kragten 79347ecc34 test(e2e): fix waitForShadow helper to use recursive shadow DOM traversal 2026-05-27 10:27:42 +02:00
Bram Kragten 01340c5e03 test(e2e): replace waitForSelector with evaluate polling in sidebar test for iOS 2026-05-27 10:27:42 +02:00
Bram Kragten f3abf60a19 test(e2e): use evaluate for card visibility check on iOS where locator can't pierce shadow DOM 2026-05-27 10:27:42 +02:00
Bram Kragten 748f616d15 test(e2e): fix evaluate argument serialization on BrowserStack iOS 2026-05-27 10:27:42 +02:00
Bram Kragten b4c88520f2 test(e2e): use recursive shadow DOM evaluate poll instead of waitForSelector on iOS 2026-05-27 10:27:41 +02:00
Bram Kragten ecada55a33 test(e2e): skip cards/dialog checks when dynamic imports fail over tunnel 2026-05-27 10:27:41 +02:00
Bram Kragten f443c0ba74 test(e2e): replace class-wildcard selector with explicit hui-* tag selectors for iPhone 2026-05-27 10:27:41 +02:00
Bram Kragten 30414703c1 test(e2e): fix WebKit dynamic-import filter and avoid concurrent waitFor on mobile 2026-05-27 10:27:41 +02:00
Bram Kragten e0e93e0fa9 test(e2e): reuse pre-existing BrowserStack page; filter infra dynamic-import errors 2026-05-27 10:27:41 +02:00
Bram Kragten b7bdad3d21 test(e2e): use serial mode + shared page to fix BrowserStack iPhone context limit 2026-05-27 10:27:41 +02:00
Bram Kragten bc55bc727d Use browserstack node SDK instead 2026-05-27 10:27:41 +02:00
Bram Kragten 7ec5f157c3 use bs-local.com for browserstack 2026-05-27 10:27:23 +02:00
Bram Kragten c1a21dff29 Update browserstack.capabilities.ts 2026-05-27 10:27:23 +02:00
Bram Kragten 2d19d40277 fix browserstack ios tests 2026-05-27 10:27:23 +02:00
Bram Kragten 73a028fa1a fix gallery tests 2026-05-27 10:27:23 +02:00
Bram Kragten 78074f7305 fix 2026-05-27 10:27:23 +02:00
Bram Kragten 1428a5b14e fix browserstack tests 2026-05-27 10:27:23 +02:00
Bram Kragten abd438fc47 fixes 2026-05-27 10:27:23 +02:00
Bram Kragten 27e2dc91ad Update e2e.yaml 2026-05-27 10:27:23 +02:00
Bram Kragten da9ce03987 Add e2e playwright tests 2026-05-27 10:27:23 +02:00
188 changed files with 6829 additions and 3474 deletions
-50
View File
@@ -1,50 +0,0 @@
name: Blocking labels
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
branches:
- dev
- master
permissions:
contents: read
jobs:
check:
name: Check for labels which block the Pull Request from being merged
runs-on: ubuntu-latest
steps:
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map(
(l) => l.name
);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(":white_check_mark: Pull Request is clear to merge after review", 2)
.addRaw("This Pull Request is not blocked by any labels which prevent it from being merged.")
.write();
}
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
@@ -65,7 +65,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
@@ -85,7 +85,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+1 -1
View File
@@ -24,7 +24,7 @@ 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+300
View File
@@ -0,0 +1,300 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
# BrowserStack runs are gated by the `e2e-browserstack` label or manual
# dispatch — see the e2e-browserstack job below. Local Chromium always runs.
workflow_dispatch:
inputs:
run-browserstack:
description: "Run BrowserStack suite"
type: boolean
default: true
env:
NODE_OPTIONS: --max_old_space_size=6144
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
if-no-files-found: error
retention-days: 3
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
if-no-files-found: error
retention-days: 3
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
# Chromium; anything longer is almost certainly an install or webServer
# hang.
timeout-minutes: 30
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
timeout-minutes: 10
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
timeout-minutes: 15
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
retention-days: 3
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
# The BrowserStack SDK manages the Local tunnel and uploads results to the
# BrowserStack Automate dashboard automatically — no tunnel action needed.
#
# Gated on:
# - manual dispatch with the run-browserstack input enabled, OR
# - a PR with the `e2e-browserstack` label applied.
# This keeps CI fast on normal PRs while still allowing on-demand runs.
e2e-browserstack:
name: E2E (BrowserStack)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_dispatch' && inputs.run-browserstack) ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'e2e-browserstack'))
environment: browserstack
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (BrowserStack)
run: yarn test:e2e:browserstack
# ── Merge local blob reports and post PR comment ───────────────────────────
# Only depends on the local job — BrowserStack reports live on the
# BrowserStack Automate dashboard and don't feed into the local blob report.
report:
name: Report
needs: [e2e-local]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
- name: Merge blob reports
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E test report\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+4 -4
View File
@@ -26,7 +26,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # master
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -97,7 +97,7 @@ jobs:
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
with:
abi: cp314
tag: musllinux_1_2
@@ -113,7 +113,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+7
View File
@@ -54,7 +54,14 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# Playwright e2e output
test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# AI tooling
.claude
.cursor
.opencode
.serena
+53
View File
@@ -0,0 +1,53 @@
# BrowserStack Automate configuration for Home Assistant frontend e2e tests.
# Credentials are read from BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY
# environment variables set in GitHub Actions (or locally).
# See: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
userName: ${BROWSERSTACK_USERNAME}
accessKey: ${BROWSERSTACK_ACCESS_KEY}
projectName: Home Assistant Frontend
buildName: e2e tests
buildIdentifier: "CI #${BUILD_NUMBER}"
# ── Platforms ────────────────────────────────────────────────────────────────
platforms:
- os: Windows
osVersion: 11
browserName: chrome
browserVersion: latest
- os: OS X
osVersion: Ventura
browserName: playwright-firefox
browserVersion: latest
- deviceName: iPad 6th
osVersion: 12
browserName: playwright-webkit
- deviceName: iPhone 12
osVersion: 14
browserName: playwright-webkit
- deviceName: Samsung Galaxy S23
osVersion: 13
browserName: chrome
realMobile: true
parallelsPerPlatform: 1
# ── Local tunnel ─────────────────────────────────────────────────────────────
# The SDK manages the BrowserStack Local tunnel automatically.
browserstackLocal: true
framework: playwright
# Pin to the latest Playwright version BrowserStack supports. Our local
# @playwright/test is newer (1.59.x) which BrowserStack does not yet support,
# causing a "Malformed endpoint" connection error if left unset.
# Update this when BrowserStack adds support for a newer version.
# Supported versions: https://www.browserstack.com/docs/automate/playwright/browsers-and-os
playwrightVersion: 1.latest
# ── Debugging ────────────────────────────────────────────────────────────────
debug: false
networkLogs: false
consoleLogs: errors
testObservability: true
+18 -1
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -321,4 +320,22 @@ module.exports.config = {
isLandingPageBuild: true,
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
defineOverlay: {
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
__DEMO__: true,
},
isProdBuild,
latestBuild,
isStatsBuild,
};
},
};
+4
View File
@@ -1,9 +1,13 @@
// @ts-check
import globals from "globals";
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
languageOptions: {
globals: globals.node,
},
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
@@ -1,4 +1,3 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
+7
View File
@@ -45,3 +45,10 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
+41
View File
@@ -0,0 +1,41 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
"gen-icons-json",
"gen-pages-e2e-test-app-dev",
"build-translations",
"build-locale-data"
),
"copy-static-e2e-test-app",
"rspack-dev-server-e2e-test-app"
)
);
gulp.task(
"build-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app",
"gen-pages-e2e-test-app-prod"
)
);
+21
View File
@@ -268,3 +268,24 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
+20
View File
@@ -201,3 +201,23 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
// Copy app static files (icons, polyfills, etc.)
fs.copySync(
polyPath("public/static"),
path.resolve(paths.e2eTestApp_output_root, "static")
);
// Copy e2e test app public files (manifest, sw stubs)
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
if (fs.existsSync(e2ePublic)) {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
copyMdiIcons(paths.e2eTestApp_output_static);
});
+1
View File
@@ -4,6 +4,7 @@ import "./clean.js";
import "./compress.js";
import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
+69 -29
View File
@@ -1,6 +1,6 @@
// Gulp task to generate third-party license notices.
import { readFile, access } from "fs/promises";
import { readFile, access, readdir } from "fs/promises";
import { generateLicenseFile } from "generate-license-file";
import gulp from "gulp";
import path from "path";
@@ -11,58 +11,98 @@ const OUTPUT_FILE = path.join(
"third-party-licenses.txt"
);
const NODE_MODULES = path.resolve(paths.root_dir, "node_modules");
// The echarts package ships an Apache-2.0 NOTICE file that must be
// redistributed alongside the compiled output per Apache License §4(d).
const NOTICE_FILES = [
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
];
const NOTICE_FILES = [path.join(NODE_MODULES, "echarts/NOTICE")];
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
// Some packages need a manual license override (e.g. they ship multiple
// license files and we must pick the right one for the bundled code).
//
// Each entry is pinned to a specific version. If a package is updated,
// this list must be reviewed and the version updated after verifying
// that the new version's license still matches. The build will fail
// if the installed version does not match the pinned version.
// that the new version's license still matches. The build will fail if
// the pinned version is no longer installed.
const LICENSE_OVERRIDES = [
{
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.7.0",
licensePath: path.resolve(
paths.root_dir,
"node_modules/type-fest/license-mit"
),
licenseFile: "license-mit",
},
];
// Locate the directory of an installed package matching an exact version.
//
// The copy we care about may be hoisted to the top-level node_modules or
// nested under a dependency when a different version occupies the hoisted
// slot (e.g. a build-only dependency pulling in an older release). Searching
// both keeps this check independent of yarn's hoisting decisions, which can
// shift when unrelated dependencies are added.
async function findPackageDir(packageName, version) {
const candidateDirs = [path.join(NODE_MODULES, packageName)];
// Collect one level of nesting: node_modules/<dep>/node_modules/<pkg> and
// node_modules/@scope/<dep>/node_modules/<pkg>.
let topLevel = [];
try {
topLevel = await readdir(NODE_MODULES, { withFileTypes: true });
} catch {
// node_modules unreadable — fall back to the hoisted candidate only.
}
for (const entry of topLevel) {
if (!entry.isDirectory() || entry.name === packageName) {
continue;
}
if (entry.name.startsWith("@")) {
const scopeDir = path.join(NODE_MODULES, entry.name);
// eslint-disable-next-line no-await-in-loop
const scoped = await readdir(scopeDir, { withFileTypes: true }).catch(
() => []
);
for (const dep of scoped) {
if (dep.isDirectory()) {
candidateDirs.push(
path.join(scopeDir, dep.name, "node_modules", packageName)
);
}
}
} else {
candidateDirs.push(
path.join(NODE_MODULES, entry.name, "node_modules", packageName)
);
}
}
for (const dir of candidateDirs) {
// eslint-disable-next-line no-await-in-loop
const pkg = await readFile(path.join(dir, "package.json"), "utf-8")
.then(JSON.parse)
.catch(() => null);
if (pkg?.version === version) {
return dir;
}
}
return null;
}
gulp.task("gen-licenses", async () => {
const licenseOverrides = {};
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
const pkgJsonPath = path.resolve(
paths.root_dir,
`node_modules/${packageName}/package.json`
);
for (const { packageName, version, licenseFile } of LICENSE_OVERRIDES) {
// eslint-disable-next-line no-await-in-loop
const packageDir = await findPackageDir(packageName, version);
let packageJSON;
try {
// eslint-disable-next-line no-await-in-loop
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
} catch {
if (!packageDir) {
throw new Error(
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
);
}
if (packageJSON.version !== version) {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
`License override for "${packageName}" is pinned to version ${version}, but that version is not installed. ` +
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
);
}
const licensePath = path.join(packageDir, licenseFile);
try {
// eslint-disable-next-line no-await-in-loop
await access(licensePath);
+20
View File
@@ -14,6 +14,7 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -231,3 +232,22 @@ gulp.task("rspack-prod-landing-page", () =>
})
)
);
gulp.task("rspack-dev-server-e2e-test-app", () =>
runDevServer({
compiler: rspack(
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
})
);
gulp.task("rspack-prod-e2e-test-app", () =>
prodBuild(
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
+11
View File
@@ -50,4 +50,15 @@ module.exports = {
),
translations_src: path.resolve(__dirname, "../src/translations"),
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
e2eTestApp_output_static: path.resolve(
__dirname,
"../test/e2e/app/dist/static"
),
e2eTestApp_output_latest: path.resolve(
__dirname,
"../test/e2e/app/dist/frontend_latest"
),
};
+6 -1
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -338,6 +337,11 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
createAppConfig,
createDemoConfig,
@@ -345,4 +349,5 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
+21
View File
@@ -0,0 +1,21 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [],
preferred_pipeline: null,
}));
// Stub for assist pipeline run — immediately sends run-end event so
// the UI does not hang waiting for a response.
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
if (onChange) {
onChange({
type: "run-end",
});
}
return null;
});
};
+21
View File
@@ -0,0 +1,21 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockCloud = (hass: MockHomeAssistant) => {
// REST mock for cloud status — returns disconnected so config panel loads
// without errors but without requiring cloud integration.
hass.mockAPI("cloud/status", () => ({
logged_in: false,
cloud: "disconnected",
prefs: {
google_enabled: false,
alexa_enabled: false,
cloudhooks: {},
remote_enabled: false,
},
google_registered: false,
alexa_registered: false,
remote_domain: null,
remote_connected: false,
remote_certificate: null,
}));
};
+5
View File
@@ -0,0 +1,5 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
+6
View File
@@ -234,6 +234,12 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
+21 -9
View File
@@ -21,7 +21,16 @@
"prepack": "pinst --disable",
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:browserstack": "node test/e2e/run-suites.mjs demo:browserstack app:browserstack gallery:browserstack",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:demo:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
"test:e2e:gallery:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.gallery.config.ts"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
@@ -34,13 +43,13 @@
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.7",
"@codemirror/lint": "6.9.6",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-datetimeformat": "7.4.8",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.14",
"@formatjs/intl-getcanonicallocales": "3.2.10",
@@ -131,14 +140,16 @@
"@babel/preset-env": "7.29.7",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.62.0",
"@html-eslint/eslint-plugin": "0.61.0",
"@lokalise/node-api": "16.0.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.13",
"@rspack/core": "2.0.8",
"@playwright/test": "1.59.1",
"@rsdoctor/rspack-plugin": "1.5.12",
"@rspack/core": "2.0.6",
"@rspack/dev-server": "2.0.3",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
@@ -157,6 +168,7 @@
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"browserstack-node-sdk": "1.53.2",
"del": "8.0.1",
"eslint": "10.4.1",
"eslint-config-prettier": "10.1.8",
@@ -186,7 +198,7 @@
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.8.4",
"prettier": "3.8.3",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
@@ -194,7 +206,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.61.0",
"typescript-eslint": "8.60.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.8",
"webpack-stats-plugin": "1.1.3",
+2 -1
View File
@@ -1,7 +1,7 @@
// Load a resource and get a promise when loading done.
// From: https://davidwalsh.name/javascript-loader
const _load = (tag: "link" | "script", url: string, type?: "module") =>
const _load = (tag: "link" | "script" | "img", url: string, type?: "module") =>
// This promise will be used by Promise.all to determine success or failure
new Promise((resolve, reject) => {
const element = document.createElement(tag);
@@ -33,4 +33,5 @@ const _load = (tag: "link" | "script", url: string, type?: "module") =>
});
export const loadCSS = (url: string) => _load("link", url);
export const loadJS = (url: string) => _load("script", url);
export const loadImg = (url: string) => _load("img", url);
export const loadModule = (url: string) => _load("script", url, "module");
+41
View File
@@ -0,0 +1,41 @@
/**
* Scroll to a specific y coordinate.
*
* Copied from paper-scroll-header-panel.
*
* @method scroll
* @param {number} top The coordinate to scroll to, along the y-axis.
* @param {boolean} smooth true if the scroll position should be smoothly adjusted.
*/
export default function scrollToTarget(element, target) {
// the scroll event will trigger _updateScrollState directly,
// However, _updateScrollState relies on the previous `scrollTop` to update the states.
// Calling _updateScrollState will ensure that the states are synced correctly.
const top = 0;
const scroller = target;
const easingFn = function easeOutQuad(t, b, c, d) {
t /= d;
return -c * t * (t - 2) + b;
};
const animationId = Math.random();
const duration = 200;
const startTime = Date.now();
const currentScrollTop = scroller.scrollTop;
const deltaScrollTop = top - currentScrollTop;
element._currentAnimationId = animationId;
(function updateFrame() {
const now = Date.now();
const elapsedTime = now - startTime;
if (elapsedTime > duration) {
scroller.scrollTop = top;
} else if (element._currentAnimationId === animationId) {
scroller.scrollTop = easingFn(
elapsedTime,
currentScrollTop,
deltaScrollTop,
duration
);
requestAnimationFrame(updateFrame.bind(element));
}
}).call(element);
}
+13
View File
@@ -3,6 +3,8 @@ import type { Map, TileLayer } from "leaflet";
// Sets up a Leaflet map on the provided DOM element
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletModuleType = typeof import("leaflet");
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletDrawModuleType = typeof import("leaflet-draw");
export const setupLeafletMap = async (
mapElement: HTMLElement,
@@ -43,6 +45,17 @@ export const setupLeafletMap = async (
return [map, Leaflet, tileLayer];
};
export const replaceTileLayer = (
leaflet: LeafletModuleType,
map: Map,
tileLayer: TileLayer
): TileLayer => {
map.removeLayer(tileLayer);
tileLayer = createTileLayer(leaflet);
tileLayer.addTo(map);
return tileLayer;
};
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
leaflet.tileLayer(
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
+3
View File
@@ -0,0 +1,3 @@
/** An empty image which can be set as src of an img element. */
export const emptyImageBase64 =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
+30 -37
View File
@@ -19,40 +19,6 @@ import type { LocalizeFunc } from "../translations/localize";
import { computeDomain } from "./compute_domain";
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../data/sensor";
// Domains whose state is a timezone-agnostic date and/or time string.
const DATE_TIME_DOMAINS = new Set(["date", "input_datetime", "time"]);
// Domains whose state is a timestamp.
const TIMESTAMP_DOMAINS = new Set([
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
]);
// Maps Intl.NumberFormat part types to ValuePart types for monetary states.
const MONETARY_TYPE_MAP: Record<string, ValuePart["type"]> = {
integer: "value",
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
};
export const computeStateDisplay = (
localize: LocalizeFunc,
stateObj: HassEntity,
@@ -172,10 +138,21 @@ const computeStateToPartsFromEntityAttributes = (
}
if (parts.length) {
const TYPE_MAP: Record<string, ValuePart["type"]> = {
integer: "value",
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
};
const valueParts: ValuePart[] = [];
for (const part of parts) {
const type = MONETARY_TYPE_MAP[part.type];
const type = TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
@@ -214,7 +191,7 @@ const computeStateToPartsFromEntityAttributes = (
return [{ type: "value", value: value }];
}
if (DATE_TIME_DOMAINS.has(domain)) {
if (["date", "input_datetime", "time"].includes(domain)) {
// If trying to display an explicit state, need to parse the explicit state to `Date` then format.
// Attributes aren't available, we have to use `state`.
@@ -273,7 +250,23 @@ const computeStateToPartsFromEntityAttributes = (
// state is a timestamp
if (
TIMESTAMP_DOMAINS.has(domain) ||
[
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
].includes(domain) ||
(domain === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
) {
+5 -6
View File
@@ -4,10 +4,9 @@ import { updateIsInstalling } from "../../data/update";
export const updateIcon = (stateObj: HassEntity, state?: string) => {
const compareState = state ?? stateObj.state;
// An install can be in progress even when the state is "off", e.g. when
// downgrading firmware. Show the installing icon regardless of state.
if (updateIsInstalling(stateObj as UpdateEntity)) {
return "mdi:package-down";
}
return compareState === "on" ? "mdi:package-up" : "mdi:package";
return compareState === "on"
? updateIsInstalling(stateObj as UpdateEntity)
? "mdi:package-down"
: "mdi:package-up"
: "mdi:package";
};
+2 -21
View File
@@ -40,25 +40,6 @@ export const numberFormatToLocale = (
}
};
// Constructing an Intl.NumberFormat is comparatively expensive, and these
// formatters are created on every numeric state render. The number of distinct
// (locale, options) combinations is small and bounded in practice, so cache the
// instances instead of rebuilding them on every call.
const numberFormatCache = new Map<string, Intl.NumberFormat>();
const getNumberFormatter = (
locale: string | string[] | undefined,
options: Intl.NumberFormatOptions
): Intl.NumberFormat => {
const key = JSON.stringify([locale, options]);
let formatter = numberFormatCache.get(key);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, options);
numberFormatCache.set(key, formatter);
}
return formatter;
};
/**
* Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility.
*
@@ -94,7 +75,7 @@ export const formatNumberToParts = (
localeOptions?.number_format !== NumberFormat.none &&
!Number.isNaN(Number(num))
) {
return getNumberFormatter(
return new Intl.NumberFormat(
locale,
getDefaultFormatOptions(num, options)
).formatToParts(Number(num));
@@ -106,7 +87,7 @@ export const formatNumberToParts = (
localeOptions?.number_format === NumberFormat.none
) {
// If NumberFormat is none, use en-US format without grouping.
return getNumberFormatter(
return new Intl.NumberFormat(
"en-US",
getDefaultFormatOptions(num, {
...options,
+2 -8
View File
@@ -11,12 +11,6 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
}
const root = rootEl || deepActiveElement()?.getRootNode() || document.body;
// A document node cannot have a textarea appended directly (only the single
// documentElement is allowed), so fall back to its body. Shadow roots and
// elements can hold the textarea directly, which keeps execCommand working
// inside dialogs that trap focus.
const container: Node =
root.nodeType === Node.DOCUMENT_NODE ? document.body : root;
const el = document.createElement("textarea");
el.value = str;
@@ -25,8 +19,8 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
el.style.top = "0";
el.style.left = "0";
el.style.opacity = "0";
container.appendChild(el);
root.appendChild(el);
el.select();
document.execCommand("copy");
container.removeChild(el);
root.removeChild(el);
};
-30
View File
@@ -1,30 +0,0 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { ReactiveControllerHost } from "lit";
import { clamp } from "../number/clamp";
// Count columns from the container's real width (not the viewport) so a
// docked sidebar is accounted for, like the dashboard sections view.
const MIN_COLUMN_WIDTH = 320;
const DEFAULT_COLUMN_GAP = 16;
const parsePx = (value: string) => parseInt(value, 10) || 0;
export const createColumnsController = (
host: ReactiveControllerHost & Element,
maxColumns: number
) =>
new ResizeController<number>(host, {
target: null,
skipInitial: true,
callback: (entries) => {
const entry = entries[0];
if (!entry) {
return maxColumns;
}
const width = entry.contentRect.width;
const gap =
parsePx(getComputedStyle(entry.target).columnGap) || DEFAULT_COLUMN_GAP;
const columns = Math.floor((width + gap) / (MIN_COLUMN_WIDTH + gap));
return clamp(columns, 1, maxColumns);
},
});
+46 -86
View File
@@ -1,23 +1,5 @@
import type { LineSeriesOption } from "echarts";
type Point = NonNullable<LineSeriesOption["data"]>[number];
interface MeanFrame {
sumX: number;
sumY: number;
count: number;
isArray: boolean;
}
interface MinMaxFrame {
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -37,47 +19,11 @@ export function downSampleLineData<
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.ceil((max - min) / Math.floor(maxDetails));
if (useMean) {
// Group points into frames, accumulating sums in insertion order.
const frames = new Map<number, MeanFrame>();
for (const point of data) {
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
sumX: x,
sumY: y,
count: 1,
isArray: Array.isArray(pointData),
});
} else {
frame.sumX += x;
frame.sumY += y;
frame.count += 1;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
const meanX = frame.sumX / frame.count;
const meanY = frame.sumY / frame.count;
const meanPoint = (
frame.isArray ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
}
return result;
}
// Min/max mode: track the min and max point per frame in insertion order.
const frames = new Map<number, MinMaxFrame>();
// Group points into frames
const frames = new Map<
number,
{ point: (typeof data)[number]; x: number; y: number }[]
>();
for (const point of data) {
const pointData = getPointData(point);
@@ -89,39 +35,53 @@ export function downSampleLineData<
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
frames.set(frameIndex, [{ point, x, y }]);
} else {
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
}
if (y > frame.maxY) {
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
frame.push({ point, x, y });
}
}
// Convert frames back to points
const result: T[] = [];
for (const frame of frames.values()) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
if (useMean) {
// Use mean values for each frame
for (const [_i, framePoints] of frames) {
const sumY = framePoints.reduce((acc, p) => acc + p.y, 0);
const meanY = sumY / framePoints.length;
const sumX = framePoints.reduce((acc, p) => acc + p.x, 0);
const meanX = sumX / framePoints.length;
const firstPoint = framePoints[0].point;
const pointData = getPointData(firstPoint);
const meanPoint = (
Array.isArray(pointData) ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
} else {
// Use min/max values for each frame
for (const [_i, framePoints] of frames) {
let minPoint = framePoints[0];
let maxPoint = framePoints[0];
for (const p of framePoints) {
if (p.y < minPoint.y) {
minPoint = p;
}
if (p.y > maxPoint.y) {
maxPoint = p;
}
}
// The order of the data must be preserved so max may be before min
if (minPoint.x > maxPoint.x) {
result.push(maxPoint.point);
}
result.push(minPoint.point);
if (minPoint.x < maxPoint.x) {
result.push(maxPoint.point);
}
}
}
+5 -36
View File
@@ -394,18 +394,6 @@ export class HaChartBase extends LitElement {
return nothing;
}
const datasets = ensureArray(this.data!);
// Index datasets by id and name so each legend item is an O(1) lookup
// instead of scanning every dataset twice. Charts can have many series.
const datasetById = new Map<unknown, (typeof datasets)[number]>();
const datasetByName = new Map<unknown, (typeof datasets)[number]>();
for (const dataset of datasets) {
if (dataset.id !== undefined && !datasetById.has(dataset.id)) {
datasetById.set(dataset.id, dataset);
}
if (dataset.name !== undefined && !datasetByName.has(dataset.name)) {
datasetByName.set(dataset.name, dataset);
}
}
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
@@ -425,10 +413,10 @@ export class HaChartBase extends LitElement {
return nothing;
}
let itemStyle: Record<string, any> = {};
let id = "";
let value = "";
let noLabelClick = false;
const name = typeof item === "string" ? item : (item.name ?? "");
let id: string;
if (typeof item === "string") {
id = item;
} else {
@@ -438,7 +426,9 @@ export class HaChartBase extends LitElement {
noLabelClick = item.noLabelClick ?? false;
}
const labelClickable = this.clickLabelForMoreInfo && !noLabelClick;
const dataset = datasetById.get(id) ?? datasetByName.get(id);
const dataset =
datasets.find((d) => d.id === id) ??
datasets.find((d) => d.name === id);
itemStyle = {
color: dataset?.color as string,
...(dataset?.itemStyle as { borderColor?: string }),
@@ -1530,9 +1520,7 @@ export class HaChartBase extends LitElement {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
/* overflow: hidden clips descenders (e.g. "g", parentheses) with a tight
line-height, so give the line box room to contain them */
line-height: var(--ha-line-height-condensed);
line-height: 1;
}
@media (hover: hover) {
.chart-legend .label.clickable:hover {
@@ -1570,25 +1558,6 @@ export class HaChartBase extends LitElement {
.chart-legend .legend-toggle ha-svg-icon {
--mdc-icon-size: 18px;
}
/* On touch devices, enlarge the toggle tap target via taller rows and
leading padding (which also separates it from the previous item), while
keeping the icon tight to its own label so the pairing stays clear.
Drop the now-pointless row gap and li padding. */
@media (pointer: coarse) {
.chart-legend ul {
row-gap: 0;
}
/* Only grow the toggle rows, not the expand/collapse chip's row. */
.chart-legend li:has(.legend-toggle) {
height: 40px;
padding: 0;
}
.chart-legend .legend-toggle {
padding: 11px;
padding-inline-end: 4px;
margin: 0;
}
}
ha-assist-chip {
height: 100%;
--_label-text-weight: 500;
@@ -147,14 +147,6 @@ export class StateHistoryChartLine extends LitElement {
this.hass.config
);
const datapoints: Record<string, any>[] = [];
// Index the hovered points by series so the per-dataset lookup below is
// O(1) instead of scanning `params` for every dataset on each mouse move.
const paramsBySeriesIndex = new Map<number, Record<string, any>>();
for (const p of params) {
if (!paramsBySeriesIndex.has(p.seriesIndex)) {
paramsBySeriesIndex.set(p.seriesIndex, p);
}
}
this._chartData.forEach((dataset, index) => {
if (
dataset.tooltip?.show === false ||
@@ -162,7 +154,9 @@ export class StateHistoryChartLine extends LitElement {
) {
return;
}
const param = paramsBySeriesIndex.get(index);
const param = params.find(
(p: Record<string, any>) => p.seriesIndex === index
);
if (param) {
datapoints.push(param);
return;
@@ -446,10 +440,6 @@ export class StateHistoryChartLine extends LitElement {
this._chartTime = new Date();
const endTime = this.endTime;
// Work with numeric epoch timestamps (ms) instead of Date objects below.
// Charts can hold a huge number of points, and allocating a Date per point
// is needless GC pressure; the "time" axis consumes numbers natively.
const endTimeMs = endTime.getTime();
const names = this.names || {};
const colors = this.colors || {};
entityStates.forEach((states, dataIdx) => {
@@ -461,9 +451,9 @@ export class StateHistoryChartLine extends LitElement {
const data: LineSeriesOption[] = [];
const pushData = (timestamp: number, datavalues: any[] | null) => {
const pushData = (timestamp: Date, datavalues: any[] | null) => {
if (!datavalues) return;
if (timestamp > endTimeMs) {
if (timestamp > endTime) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
@@ -634,11 +624,11 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.target_temp_low
);
series.push(targetHigh, targetLow);
pushData(entityState.last_changed, series);
pushData(new Date(entityState.last_changed), series);
} else {
const target = safeParseFloat(entityState.attributes.temperature);
series.push(target);
pushData(entityState.last_changed, series);
pushData(new Date(entityState.last_changed), series);
}
});
} else if (domain === "humidifier") {
@@ -756,27 +746,31 @@ export class StateHistoryChartLine extends LitElement {
} else {
series.push(entityState.state === "on" ? current : null);
}
pushData(entityState.last_changed, series);
pushData(new Date(entityState.last_changed), series);
});
} else {
addDataSet(states.entity_id, name, color);
let lastValue: number;
let lastDate: number;
let lastNullDate: number | null = null;
let lastDate: Date;
let lastNullDate: Date | null = null;
// Process chart data.
// When state is `unknown`, calculate the value and break the line.
const processData = (entityState: LineChartState) => {
const value = safeParseFloat(entityState.state);
const date = entityState.last_changed;
const date = new Date(entityState.last_changed);
if (value !== null && lastNullDate) {
const dateTime = date.getTime();
const lastNullDateTime = lastNullDate.getTime();
const lastDateTime = lastDate?.getTime();
const tmpValue =
(value - lastValue) *
((lastNullDate - lastDate) / (date - lastDate)) +
((lastNullDateTime - lastDateTime) /
(dateTime - lastDateTime)) +
lastValue;
pushData(lastNullDate, [tmpValue]);
pushData(lastNullDate + 1, [null]);
pushData(new Date(lastNullDateTime + 1), [null]);
pushData(date, [value]);
lastDate = date;
lastValue = value;
@@ -815,17 +809,17 @@ export class StateHistoryChartLine extends LitElement {
}
// Add an entry for final values
pushData(endTimeMs, prevValues);
pushData(endTime, prevValues);
// For sensors, append current state if viewing recent data
const nowMs = Date.now();
const now = new Date();
// allow 1s of leeway for "now"
const isUpToNow = nowMs - endTimeMs <= 1000;
const isUpToNow = now.getTime() - endTime.getTime() <= 1000;
if (domain === "sensor" && isUpToNow && data.length === 1) {
const stateObj = this.hass.states[states.entity_id];
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([nowMs, currentValue]);
data[0].data!.push([now, currentValue]);
trackY(currentValue);
}
}
+2 -8
View File
@@ -215,16 +215,10 @@ export class HaDataTable extends LitElement {
if (clear) {
this._checkedRows = [];
}
// Map + Set keep a large selection O(rows + ids) instead of O(rows × ids).
const rowLookup = new Map(
(this._filteredData || []).map((data) => [data[this.id], data])
);
const checkedRows = new Set(this._checkedRows);
ids.forEach((id) => {
const row = rowLookup.get(id);
if (row?.selectable !== false && !checkedRows.has(id)) {
const row = this._filteredData?.find((data) => data[this.id] === id);
if (row?.selectable !== false && !this._checkedRows.includes(id)) {
this._checkedRows.push(id);
checkedRows.add(id);
}
});
this._lastSelectedRowId = null;
-1
View File
@@ -183,7 +183,6 @@ export class HaControlSelectMenu extends LitElement {
gap: 10px;
width: 100%;
user-select: none;
font-family: var(--ha-font-family-body, inherit);
font-style: normal;
font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.25px;
+5 -21
View File
@@ -12,20 +12,6 @@ import type {
HaFormSelectSchema,
} from "./types";
/**
* The underlying select returns option values as strings. Map a selected value
* back to its original option value so the source type is retained (for example
* a number coming from a backend `vol.In` schema), falling back to the value
* itself when no option matches.
*/
export const matchSelectOptionValue = (
options: HaFormSelectSchema["options"],
value: string
): HaFormSelectData => {
const option = options.find((opt) => String(opt[0]) === String(value));
return option ? option[0] : value;
};
@customElement("ha-form-select")
export class HaFormSelect extends LitElement implements HaFormElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -80,18 +66,16 @@ export class HaFormSelect extends LitElement implements HaFormElement {
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
let value: HaFormSelectData | undefined = ev.detail.value;
if (value === "") {
value = undefined;
} else if (value != null) {
value = matchSelectOptionValue(this.schema.options, value);
}
let value: string | undefined = ev.detail.value;
if (value === this.data) {
return;
}
if (value === "") {
value = undefined;
}
fireEvent(this, "value-changed", {
value,
});
-2
View File
@@ -41,8 +41,6 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
),
esphome: () =>
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
matter: () =>
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
};
@customElement("ha-icon")
@@ -354,9 +354,7 @@ export class HaSerialPortSelector extends LitElement {
}
private get _selectorDomain(): string | undefined {
// `domain` is the integration domain even in options flows, where the flow
// handler is the config entry id instead.
return this.context?.domain;
return this.context?.handler;
}
private _memoRecommendedDomains = memoizeOne(
+27 -49
View File
@@ -2,19 +2,12 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../common/navigate";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-icon-button-arrow-prev";
import "./ha-menu-button";
const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
export const haTopAppBarFixedStyles = css`
:host {
display: block;
position: relative;
height: 100vh;
overflow: hidden;
--total-top-app-bar-height: calc(
var(--header-height, 0px) + var(--sub-row-height, 0px)
);
@@ -25,11 +18,10 @@ export const haTopAppBarFixedStyles = css`
box-sizing: border-box;
color: var(--app-header-text-color, #fff);
background-color: var(--app-header-background-color, var(--primary-color));
position: absolute;
position: fixed;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
width: 100%;
width: var(--ha-top-app-bar-width, 100%);
z-index: 4;
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
@@ -121,17 +113,17 @@ export const haTopAppBarFixedStyles = css`
}
.top-app-bar-fixed-adjust {
box-sizing: border-box;
position: absolute;
top: calc(
height: calc(
100vh - var(--total-top-app-bar-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
padding-top: calc(
var(--total-top-app-bar-height, 0px) + var(--safe-area-inset-top, 0px)
);
bottom: 0;
inset-inline-start: 0;
inset-inline-end: 0;
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
overflow: auto;
}
:host([narrow]) .top-app-bar-fixed-adjust {
@@ -143,16 +135,12 @@ export const haTopAppBarFixedStyles = css`
export class HaTopAppBarFixed extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "back-button", type: Boolean }) backButton = false;
@property({ attribute: "center-title", type: Boolean }) centerTitle = false;
@query(".top-app-bar") protected _barElement!: HTMLElement;
@query(".sub-row") protected _subRowElement?: HTMLElement;
@query(".top-app-bar-fixed-adjust") protected _scrollElement?: HTMLElement;
@state() private _hasSubRow = false;
private _scrollTarget?: HTMLElement | Window;
@@ -161,13 +149,14 @@ export class HaTopAppBarFixed extends LitElement {
@property({ attribute: false })
public get scrollTarget(): HTMLElement | Window {
return this._scrollTarget || this._scrollElement || window;
return this._scrollTarget || window;
}
public set scrollTarget(value: HTMLElement | Window) {
const old = this.scrollTarget;
this._unregisterListeners();
this._scrollTarget = value;
this._updateBarPosition();
this.requestUpdate("scrollTarget", old);
if (this.isConnected) {
this._registerListeners();
@@ -189,6 +178,7 @@ export class HaTopAppBarFixed extends LitElement {
if (this.hasUpdated) {
this._observeSubRowHeight();
this._updateSubRowHeight();
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
@@ -210,14 +200,16 @@ export class HaTopAppBarFixed extends LitElement {
<div class="row">
${paneHeader
? html`<section class="section" id="title">
${this._renderNavigationIcon()} ${title}
<slot name="navigationIcon"></slot>
${title}
</section>`
: nothing}
<section class="section" id="navigation">
${paneHeader
? nothing
: html`${this._renderNavigationIcon()}
${this.centerTitle ? nothing : title}`}
: html`<slot name="navigationIcon"></slot> ${this.centerTitle
? nothing
: title}`}
</section>
${!paneHeader && this.centerTitle
? html`<section class="section center">${title}</section>`
@@ -233,22 +225,8 @@ export class HaTopAppBarFixed extends LitElement {
`;
}
private _renderNavigationIcon() {
return html`
<slot name="navigationIcon">
${this.backButton
? html`
<ha-icon-button-arrow-prev
@click=${this._handleBackClick}
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button></ha-menu-button>`}
</slot>
`;
}
protected _renderContent() {
return html`<div class="top-app-bar-fixed-adjust ha-scrollbar">
return html`<div class="top-app-bar-fixed-adjust">
<slot></slot>
</div>`;
}
@@ -257,6 +235,7 @@ export class HaTopAppBarFixed extends LitElement {
super.firstUpdated(changedProperties);
this._observeSubRowHeight();
this._updateSubRowHeight();
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
@@ -274,6 +253,13 @@ export class HaTopAppBarFixed extends LitElement {
this._unregisterListeners();
}
protected _updateBarPosition() {
if (this._barElement) {
this._barElement.style.position =
this.scrollTarget === window ? "" : "absolute";
}
}
protected _syncScrollState = () => {
const scrollTop =
this.scrollTarget instanceof Window
@@ -282,11 +268,6 @@ export class HaTopAppBarFixed extends LitElement {
this._barElement?.classList.toggle("scrolled", scrollTop > 0);
};
private _handleBackClick(ev: Event) {
ev.stopPropagation();
goBack();
}
protected _registerListeners() {
this.scrollTarget.addEventListener(
"scroll",
@@ -333,10 +314,7 @@ export class HaTopAppBarFixed extends LitElement {
this.style.setProperty("--sub-row-height", `${subRowHeight}px`);
};
static override styles: CSSResultGroup = [
haStyleScrollbar,
haTopAppBarFixedStyles,
];
static override styles: CSSResultGroup = haTopAppBarFixedStyles;
}
declare global {
+6 -16
View File
@@ -85,25 +85,15 @@ export class HaTTSVoicePicker extends LitElement {
await listTTSVoices(this.hass, this.engineId, this.language)
).voices;
const valueIsValid =
this.value &&
this._voices?.some((voice) => voice.voice_id === this.value);
if (valueIsValid) {
if (!this.value) {
return;
}
// The current value is missing or no longer valid for the loaded voices.
// When a voice is required, auto-select the first one (the <ha-select>
// already displays it) so the value is propagated to the parent;
// otherwise clear it.
const newValue =
this.required && this._voices?.length
? this._voices[0].voice_id
: undefined;
if (newValue !== this.value) {
this.value = newValue;
if (
!this._voices ||
!this._voices.find((voice) => voice.voice_id === this.value)
) {
this.value = undefined;
fireEvent(this, "value-changed", { value: this.value });
}
}
@@ -29,7 +29,6 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
<div
class=${classMap({
"top-app-bar-fixed-adjust": true,
"ha-scrollbar": true,
"top-app-bar-fixed-adjust--pane": this.pane,
})}
>
@@ -131,7 +130,12 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
.top-app-bar-fixed-adjust--pane {
display: flex;
overflow: hidden;
height: calc(
100vh - var(--total-top-app-bar-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
}
.pane {
@@ -163,7 +167,6 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
position: relative;
flex: 1;
height: 100%;
min-width: 0;
}
.top-app-bar-fixed-adjust--pane .content {
+13
View File
@@ -0,0 +1,13 @@
import timezones from "google-timezones-json";
export const createTimezoneListEl = () => {
const list = document.createElement("datalist");
list.id = "timezones";
Object.keys(timezones).forEach((key) => {
const option = document.createElement("option");
option.value = key;
option.innerText = timezones[key];
list.appendChild(option);
});
return list;
};
+10 -24
View File
@@ -73,44 +73,30 @@ export const getEntities = (
let entityIds = Object.keys(hass.states);
// These run over every entity, so use Sets for O(1) membership instead of
// repeated Array.includes scans.
if (includeEntities) {
const includeEntitiesSet = new Set(includeEntities);
entityIds = entityIds.filter((entityId) =>
includeEntitiesSet.has(entityId)
includeEntities.includes(entityId)
);
}
if (excludeEntities) {
const excludeEntitiesSet = new Set(excludeEntities);
entityIds = entityIds.filter(
(entityId) => !excludeEntitiesSet.has(entityId)
(entityId) => !excludeEntities.includes(entityId)
);
}
if (includeDomains) {
const includeDomainsSet = new Set(includeDomains);
entityIds = entityIds.filter((eid) =>
includeDomainsSet.has(computeDomain(eid))
includeDomains.includes(computeDomain(eid))
);
}
if (excludeDomains) {
const excludeDomainsSet = new Set(excludeDomains);
entityIds = entityIds.filter(
(eid) => !excludeDomainsSet.has(computeDomain(eid))
(eid) => !excludeDomains.includes(computeDomain(eid))
);
}
// These values are the same for every entity, so compute them once instead
// of inside the map over (potentially thousands of) entities.
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const domainNames = new Map<string, string>();
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
@@ -124,12 +110,12 @@ export const getEntities = (
hass.floors
);
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
if (domainName === undefined) {
domainName = domainToName(hass.localize, domain);
domainNames.set(domain, domainName);
}
const domainName = domainToName(hass.localize, computeDomain(entityId));
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
+6 -8
View File
@@ -725,18 +725,16 @@ export const mergeHistoryResults = (
}
const newLineItem: LineChartUnit = { ...historyItem, data: [] };
const historyDataByEntity = new Map(
historyItem.data.map((d) => [d.entity_id, d])
);
const ltsDataByEntity = new Map(ltsItem.data.map((d) => [d.entity_id, d]));
const entities = new Set([
...historyDataByEntity.keys(),
...ltsDataByEntity.keys(),
...historyItem.data.map((d) => d.entity_id),
...ltsItem.data.map((d) => d.entity_id),
]);
for (const entity of entities) {
const historyDataItem = historyDataByEntity.get(entity);
const ltsDataItem = ltsDataByEntity.get(entity);
const historyDataItem = historyItem.data.find(
(d) => d.entity_id === entity
);
const ltsDataItem = ltsItem.data.find((d) => d.entity_id === entity);
if (!historyDataItem || !ltsDataItem) {
newLineItem.data.push(historyDataItem || ltsDataItem!);
+7
View File
@@ -43,6 +43,11 @@ export const lightSupportsColorMode = (
mode: LightColorMode
) => entity.attributes.supported_color_modes?.includes(mode) || false;
export const lightIsInColorMode = (entity: LightEntity) =>
(entity.attributes.color_mode &&
modesSupportingColor.includes(entity.attributes.color_mode)) ||
false;
export const lightSupportsColor = (entity: LightEntity) =>
entity.attributes.supported_color_modes?.some((mode) =>
modesSupportingColor.includes(mode)
@@ -154,3 +159,5 @@ export const computeDefaultFavoriteColors = (
return colors;
};
export const formatTempColor = (value: number) => `${value} K`;
+1 -3
View File
@@ -128,13 +128,11 @@ export const addMatterDevice = (hass: HomeAssistant) => {
export const commissionMatterDevice = (
hass: HomeAssistant,
code: string,
networkOnly: boolean
code: string
): Promise<void> =>
hass.callWS({
type: "matter/commission",
code,
network_only: networkOnly,
});
export const acceptSharedMatterDevice = (
+21 -32
View File
@@ -146,20 +146,10 @@ export const filterUpdateEntitiesParameterized = (
return updateCanInstall(entity, showSkipped);
});
export const installUpdates = (
hass: HomeAssistant,
entityIds: string[],
notifyOnError = true
) =>
hass.callService(
"update",
"install",
{
entity_id: entityIds,
},
undefined,
notifyOnError
);
export const installUpdates = (hass: HomeAssistant, entityIds: string[]) =>
hass.callService("update", "install", {
entity_id: entityIds,
});
export const checkForEntityUpdates = async (
element: HTMLElement,
@@ -231,24 +221,6 @@ export const computeUpdateStateDisplay = (
const state = stateObj.state;
const attributes = stateObj.attributes;
// An install can be in progress even when the state is "off", e.g. when
// downgrading firmware (installed_version is newer than latest_version).
// Show the installing status regardless of state in that case.
if (updateIsInstalling(stateObj)) {
const supportsProgress =
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
attributes.update_percentage !== null;
if (supportsProgress) {
return hass.localize("ui.card.update.installing_with_progress", {
progress: formatNumber(attributes.update_percentage!, hass.locale, {
maximumFractionDigits: attributes.display_precision,
minimumFractionDigits: attributes.display_precision,
}),
});
}
return hass.localize("ui.card.update.installing");
}
if (state === "off") {
const isSkipped =
attributes.latest_version &&
@@ -259,6 +231,23 @@ export const computeUpdateStateDisplay = (
return hass.formatEntityState(stateObj);
}
if (state === "on") {
if (updateIsInstalling(stateObj)) {
const supportsProgress =
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
attributes.update_percentage !== null;
if (supportsProgress) {
return hass.localize("ui.card.update.installing_with_progress", {
progress: formatNumber(attributes.update_percentage!, hass.locale, {
maximumFractionDigits: attributes.display_precision,
minimumFractionDigits: attributes.display_precision,
}),
});
}
return hass.localize("ui.card.update.installing");
}
}
return hass.formatEntityState(stateObj);
};
@@ -10,21 +10,13 @@ import "../../components/ha-button";
import type { HaSwitch } from "../../components/ha-switch";
import type { ConfigEntryMutableParams } from "../../data/config_entries";
import { updateConfigEntry } from "../../data/config_entries";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showAlertDialog } from "../generic/show-dialog-box";
import type { ConfigEntrySystemOptionsDialogParams } from "./show-dialog-config-entry-system-options";
interface SystemOptionsState {
disableNewEntities: boolean;
disablePolling: boolean;
}
@customElement("dialog-config-entry-system-options")
class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptionsState>()(
LitElement
) {
class DialogConfigEntrySystemOptions extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _disableNewEntities!: boolean;
@@ -46,13 +38,6 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
this._error = undefined;
this._disableNewEntities = params.entry.pref_disable_new_entities;
this._disablePolling = params.entry.pref_disable_polling;
this._initDirtyTracking(
{ type: "shallow" },
{
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
}
);
this._open = true;
}
@@ -83,7 +68,7 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
) || this._params.entry.domain,
}
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
@@ -150,7 +135,7 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting || !this.isDirtyState}
.disabled=${this._submitting}
>
${this.hass.localize(
"ui.dialogs.config_entry_system_options.update"
@@ -164,19 +149,11 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
private _disableNewEntitiesChanged(ev: Event): void {
this._error = undefined;
this._disableNewEntities = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private _disablePollingChanged(ev: Event): void {
this._error = undefined;
this._disablePolling = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private async _updateEntry(): Promise<void> {
@@ -403,7 +403,6 @@ class DataEntryFlowDialog extends LitElement {
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.domain=${this._params.domain ?? this._step.handler}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
></step-flow-form>
+1 -5
View File
@@ -35,10 +35,6 @@ class StepFlowForm extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// The integration domain this flow belongs to. Unlike `step.handler`, this is
// the domain even for options flows (where the handler is the config entry id).
@property({ attribute: false }) public domain?: string;
@state() private _loading = false;
@state() private _stepData?: Record<string, any>;
@@ -112,7 +108,7 @@ class StepFlowForm extends LitElement {
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler, domain: this.domain }}
.context=${{ handler: step.handler }}
></ha-form>`
: nothing}
</div>
+1 -3
View File
@@ -40,9 +40,7 @@ export class HaMoreInfoAddTo extends LitElement {
@state() private _loading = true;
private async _loadActions() {
this._defaultActions = this._config?.user?.is_admin
? getDefaultAddToActions()
: [];
this._defaultActions = getDefaultAddToActions();
this._externalActions = [];
if (this._config?.auth.external?.config.hasEntityAddTo) {
+2 -2
View File
@@ -266,8 +266,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _shouldShowAddEntityTo(): boolean {
// When new_triggers_conditions labs feature is promoted, this whole check can be removed.
return (
(this._newTriggersAndConditions && !!this.hass.user?.is_admin) ||
this._newTriggersAndConditions ||
!!this.hass.auth.external?.config.hasEntityAddTo
);
}
@@ -1103,7 +1104,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
.title .breadcrumb {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-m);
font-family: var(--ha-font-family-heading, inherit);
line-height: 16px;
--mdc-icon-size: 16px;
padding: var(--ha-space-1);
+5 -17
View File
@@ -22,7 +22,6 @@ interface EntityInfo {
entityId: string;
entityName: string | undefined;
areaId: string | undefined;
deviceId: string | undefined;
}
@customElement("more-info-content")
@@ -121,7 +120,7 @@ class MoreInfoContent extends LitElement {
hass.entities,
hass.devices
);
const { area, device } = getEntityContext(
const { area } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
@@ -129,8 +128,7 @@ class MoreInfoContent extends LitElement {
hass.floors
);
const areaId = area?.area_id;
const deviceId = device?.id;
return { entityId, entityName, areaId, deviceId };
return { entityId, entityName, areaId };
})
.filter(Boolean) as EntityInfo[];
@@ -142,20 +140,10 @@ class MoreInfoContent extends LitElement {
const areaIds = new Set(entityInfos.map((info) => info.areaId));
const allSameArea = areaIds.size === 1;
// Check if all entities belong to the same device
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
const allSameDevice = deviceIds.size === 1;
// Build name and state content config based on conditions
const name: EntityNameItem[] = [{ type: "device" }];
// Build name and state content config based on conditions. The device name
// is redundant when every member belongs to the same device, so omit it
// (and fall back to the entity name so the tile still has a label).
const name: EntityNameItem[] = [];
if (!allSameDevice) {
name.push({ type: "device" });
}
if (!allSameEntityName || allSameDevice) {
if (!allSameEntityName) {
name.push({ type: "entity" });
}
-67
View File
@@ -19,64 +19,6 @@ declare const __WB_MANIFEST__: Parameters<typeof precacheAndRoute>[0];
const noFallBackRegEx =
/\/(api|static|auth|frontend_latest|frontend_es5|local)\/.*/;
// Camera / image proxy endpoints that carry credentials in the URL.
// We pre-validate the credential in the service worker so obviously invalid
// requests (signature expired, token missing) never reach the server and
// don't trigger spurious "Login attempt" warnings from http.ban after BFCache
// restore, tab resume, network change, or any other browser-initiated replay
// of a stale `<img>` URL.
const proxyPathRegEx =
/^\/api\/(camera_proxy_stream|camera_proxy|image_proxy)\//;
// Reject signatures this many ms before their nominal expiry to absorb small
// client/server clock differences. Erring this direction only ever turns a
// would-be valid request into a local 401; we cannot err the other way without
// re-introducing the warnings this filter exists to prevent.
const JWT_EXPIRY_SKEW_MS = 5000;
const base64UrlDecode = (input: string): string => {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return atob(padded);
};
const isJwtExpired = (jwt: string): boolean => {
try {
const parts = jwt.split(".");
if (parts.length !== 3) {
return false;
}
const payload = JSON.parse(base64UrlDecode(parts[1]));
if (typeof payload.exp !== "number") {
return false;
}
return payload.exp * 1000 < Date.now() + JWT_EXPIRY_SKEW_MS;
} catch (_err) {
// If we can't parse the JWT for any reason, defer to the server.
return false;
}
};
const handleProxyRequest: RouteHandler = async ({ request }) => {
const req = request as Request;
const url = new URL(req.url);
const token = url.searchParams.get("token");
if (token === "undefined" || token === "null" || token === "") {
return new Response(null, { status: 401, statusText: "Invalid token" });
}
const authSig = url.searchParams.get("authSig");
if (authSig && isJwtExpired(authSig)) {
return new Response(null, {
status: 401,
statusText: "Signature expired",
});
}
return fetch(req);
};
const initRouting = () => {
precacheAndRoute(__WB_MANIFEST__, {
// Ignore all URL parameters.
@@ -117,15 +59,6 @@ const initRouting = () => {
})
);
// Short-circuit camera/image proxy requests with an expired signature or a
// missing/undefined token so they don't hit core and get logged as invalid
// login attempts. Registered before the generic /api route below so it wins.
registerRoute(
({ url, request }) =>
proxyPathRegEx.test(url.pathname) && request.method === "GET",
handleProxyRequest
);
// Get api from network.
registerRoute(/\/(api|auth)\/.*/, new NetworkOnly());
+4
View File
@@ -420,6 +420,10 @@ export const provideHass = (
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
},
],
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
...overrideData,
};
+35 -17
View File
@@ -2,8 +2,9 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { goBack } from "../common/navigate";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-button";
import "../components/ha-top-app-bar-fixed";
import "../components/ha-menu-button";
import type { HomeAssistant } from "../types";
import "../components/ha-alert";
@@ -20,22 +21,18 @@ class HassErrorScreen extends LitElement {
@property() public error?: string;
protected render(): TemplateResult {
if (!this.toolbar) {
return this._renderContent();
}
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!(this.rootnav || history.state?.root)}
>
${this._renderContent()}
</ha-top-app-bar-fixed>
`;
}
private _renderContent(): TemplateResult {
return html`
${this.toolbar
? html`<div class="toolbar">
${this.rootnav || history.state?.root
? html`<ha-menu-button></ha-menu-button>`
: html`
<ha-icon-button-arrow-prev
@click=${this._handleBack}
></ha-icon-button-arrow-prev>
`}
</div>`
: ""}
<div class="content">
<ha-alert alert-type="error">${this.error}</ha-alert>
<slot>
@@ -59,9 +56,30 @@ class HassErrorScreen extends LitElement {
height: 100%;
background-color: var(--primary-background-color);
}
.toolbar {
display: flex;
align-items: center;
font-size: var(--ha-font-size-xl);
height: var(--header-height);
padding: 8px 12px;
pointer-events: none;
background-color: var(--app-header-background-color);
font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
box-sizing: border-box;
}
@media (max-width: 599px) {
.toolbar {
padding: 4px;
}
}
ha-icon-button-arrow-prev {
pointer-events: auto;
}
.content {
color: var(--primary-text-color);
height: 100%;
height: calc(100% - var(--header-height));
display: flex;
padding: 16px;
align-items: center;
+64 -34
View File
@@ -1,8 +1,11 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../components/ha-top-app-bar-fixed";
import { goBack } from "../common/navigate";
import "../components/ha-spinner";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
@customElement("hass-loading-screen")
@@ -19,22 +22,18 @@ class HassLoadingScreen extends LitElement {
@property() public message?: string;
protected render(): TemplateResult {
if (this.noToolbar) {
return this._renderContent();
}
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!(this.rootnav || history.state?.root)}
>
${this._renderContent()}
</ha-top-app-bar-fixed>
`;
}
private _renderContent(): TemplateResult {
return html`
${this.noToolbar
? ""
: html`<div class="toolbar">
${this.rootnav || history.state?.root
? html`<ha-menu-button></ha-menu-button>`
: html`
<ha-icon-button-arrow-prev
@click=${this._handleBack}
></ha-icon-button-arrow-prev>
`}
</div>`}
<div class="content">
<ha-spinner></ha-spinner>
${this.message
@@ -44,24 +43,55 @@ class HassLoadingScreen extends LitElement {
`;
}
static styles: CSSResultGroup = css`
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
.content {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#loading-text {
max-width: 350px;
margin-top: var(--ha-space-4);
}
`;
private _handleBack() {
goBack();
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
.toolbar {
display: flex;
align-items: center;
font-size: var(--ha-font-size-xl);
height: var(--header-height);
padding: 8px 12px;
pointer-events: none;
background-color: var(--app-header-background-color);
font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
box-sizing: border-box;
}
@media (max-width: 599px) {
.toolbar {
padding: 4px;
}
}
ha-menu-button,
ha-icon-button-arrow-prev {
pointer-events: auto;
}
.content {
height: calc(100% - var(--header-height));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#loading-text {
max-width: 350px;
margin-top: 16px;
}
`,
];
}
}
declare global {
+6 -4
View File
@@ -6,9 +6,6 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
superClass: T
) =>
class extends superClass {
/** Provided by `DirtyStateProviderMixin`. */
declare isDirtyState: boolean;
private _handleClick = async (e: MouseEvent) => {
// get the right target, otherwise the composedPath would return <home-assistant> in the new event
const target = e.composedPath()[0];
@@ -36,7 +33,7 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (this.isDirtyState) {
if (this.isDirty) {
window.addEventListener("click", this._handleClick, true);
window.addEventListener("beforeunload", this._handleUnload);
} else {
@@ -50,6 +47,11 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
this._removeListeners();
}
// eslint-disable-next-line @typescript-eslint/class-literal-property-style
protected get isDirty(): boolean {
return false;
}
protected async promptDiscardChanges(): Promise<boolean> {
return true;
}
+4
View File
@@ -16,6 +16,7 @@ import type { HaDropdownItem } from "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-list";
import "../../components/ha-list-item";
import "../../components/ha-menu-button";
import "../../components/ha-spinner";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
@@ -118,6 +119,7 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
if (!this._entityRegistry) {
return html`
<ha-two-pane-top-app-bar-fixed .narrow=${this.narrow}>
<ha-menu-button slot="navigationIcon"></ha-menu-button>
<div slot="title">
${this.hass.localize("ui.components.calendar.my_calendars")}
</div>
@@ -152,6 +154,8 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
footer
.narrow=${this.narrow}
>
<ha-menu-button slot="navigationIcon"></ha-menu-button>
${!showPane
? html`<ha-dropdown slot="title">
<ha-button slot="trigger">
+17 -4
View File
@@ -1,8 +1,11 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
@@ -87,12 +90,22 @@ class PanelClimate extends LitElement {
this._setLovelace();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._searchParams.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button slot="navigationIcon"></ha-menu-button>`}
<div slot="title">${this.hass.localize("panel.climate")}</div>
${this._lovelace
? html`
@@ -23,28 +23,18 @@ import {
} from "../../../data/application_credential";
import type { IntegrationManifest } from "../../../data/integration";
import { domainToName } from "../../../data/integration";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { AddApplicationCredentialDialogParams } from "./show-dialog-add-application-credential";
interface CredentialFormState {
domain: string;
name: string;
clientId: string;
clientSecret: string;
}
interface Domain {
id: string;
name: string;
}
@customElement("dialog-add-application-credential")
export class DialogAddApplicationCredential extends DirtyStateProviderMixin<CredentialFormState>()(
LitElement
) {
export class DialogAddApplicationCredential extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loading = false;
@@ -86,7 +76,6 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
this._error = undefined;
this._loading = false;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, this._currentState());
this._fetchConfig();
}
@@ -111,7 +100,10 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
<ha-dialog
.open=${this._open}
@closed=${this._abortDialog}
.preventScrimClose=${this.isDirtyState}
.preventScrimClose=${!!this._domain ||
!!this._name ||
!!this._clientId ||
!!this._clientSecret}
.headerTitle=${this.hass.localize(
"ui.panel.config.application_credentials.editor.caption"
)}
@@ -292,7 +284,6 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
ev.stopPropagation();
this._domain = ev.detail.value;
this._updateDescription();
this._updateDirtyState(this._currentState());
}
private async _updateDescription() {
@@ -316,16 +307,6 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
const name = (ev.target as any).name;
const value = (ev.target as any).value;
this[`_${name}`] = value;
this._updateDirtyState(this._currentState());
}
private _currentState(): CredentialFormState {
return {
domain: this._domain || "",
name: this._name || "",
clientId: this._clientId || "",
clientSecret: this._clientSecret || "",
};
}
private _abortDialog() {
@@ -32,7 +32,6 @@ import {
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import type { ObjectSelector, Selector } from "../../../../../data/selector";
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
@@ -57,15 +56,15 @@ const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
const MASKED_FIELDS = ["password", "secret", "token"];
@customElement("supervisor-app-config")
class SupervisorAppConfig extends DirtyStateProviderMixin<
Record<string, unknown>
>()(LitElement) {
class SupervisorAppConfig extends LitElement {
@property({ attribute: false }) public addon!: HassioAddonDetails;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@state() private _configHasChanged = false;
@state() private _valid = true;
@state() private _canShowSchema = false;
@@ -352,7 +351,9 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
<div class="card-actions right">
<ha-progress-button
@click=${this._saveTapped}
.disabled=${this.disabled || !this.isDirtyState || !this._valid}
.disabled=${this.disabled ||
!this._configHasChanged ||
!this._valid}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
@@ -376,7 +377,6 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
protected updated(changedProperties: PropertyValues): void {
if (changedProperties.has("addon")) {
this._options = { ...this.addon.options };
this._initDirtyTracking({ type: "deep" }, this.addon.options);
}
super.updated(changedProperties);
if (
@@ -415,13 +415,11 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
private _configChanged(ev): void {
if (this.addon.schema && this._canShowSchema && !this._yamlMode) {
this._valid = true;
this._configHasChanged = true;
this._options = ev.detail.value;
this._updateDirtyState(ev.detail.value);
} else {
this._configHasChanged = true;
this._valid = ev.detail.isValid;
if (ev.detail.isValid) {
this._updateDirtyState(ev.detail.value);
}
}
}
@@ -452,7 +450,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
};
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._markDirtyStateClean();
this._configHasChanged = false;
const eventdata = {
success: true,
response: undefined,
@@ -471,7 +469,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
}
private async _saveTapped(ev: CustomEvent): Promise<void> {
if (this.disabled || !this.isDirtyState || !this._valid) {
if (this.disabled || !this._configHasChanged || !this._valid) {
return;
}
@@ -501,7 +499,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
options,
});
this._markDirtyStateClean();
this._configHasChanged = false;
if (this.addon?.state === "started") {
await suggestSupervisorAppRestart(this, this.hass, this.addon);
}
@@ -15,16 +15,13 @@ import type {
} from "../../../../../data/hassio/addon";
import { setHassioAddonOption } from "../../../../../data/hassio/addon";
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
@customElement("supervisor-app-network")
class SupervisorAppNetwork extends DirtyStateProviderMixin<
Record<string, number | null>
>()(LitElement) {
class SupervisorAppNetwork extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public addon!: HassioAddonDetails;
@@ -33,19 +30,19 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
@state() private _showOptional = false;
@state() private _configHasChanged = false;
@state() private _error?: string;
@state() private _config?: Record<string, number | null>;
@state() private _config?: Record<string, any>;
protected render() {
if (!this._config) {
return nothing;
}
const config = this._config;
const hasHiddenOptions = Object.keys(config).find(
(entry) => config[entry] === null
const hasHiddenOptions = Object.keys(this._config).find(
(entry) => this._config![entry] === null
);
return html`
@@ -101,7 +98,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
</ha-progress-button>
<ha-progress-button
@click=${this._saveTapped}
.disabled=${!this.isDirtyState || this.disabled}
.disabled=${!this._configHasChanged || this.disabled}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
@@ -118,10 +115,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
}
private _createSchema = memoizeOne(
(
config: Record<string, number | null>,
showOptional: boolean
): HaFormSchema[] =>
(config: Record<string, number>, showOptional: boolean): HaFormSchema[] =>
(showOptional
? Object.keys(config)
: Object.keys(config).filter((entry) => config[entry] !== null)
@@ -147,14 +141,12 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
item.name;
private _setNetworkConfig(): void {
const config = this.addon.network || {};
this._config = config;
this._initDirtyTracking({ type: "shallow" }, config);
this._config = this.addon.network || {};
}
private _configChanged(ev: CustomEvent): void {
private async _configChanged(ev: CustomEvent): Promise<void> {
this._configHasChanged = true;
this._config = ev.detail.value;
this._updateDirtyState(ev.detail.value);
}
private async _resetTapped(ev: CustomEvent): Promise<void> {
@@ -169,7 +161,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._markDirtyStateClean();
this._configHasChanged = false;
const eventdata = {
success: true,
response: undefined,
@@ -196,14 +188,14 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
}
private async _saveTapped(ev: CustomEvent): Promise<void> {
if (!this.isDirtyState || this.disabled) {
if (!this._configHasChanged || this.disabled) {
return;
}
const button = ev.currentTarget as any;
this._error = undefined;
const networkconfiguration: Record<string, number | null> = {};
const networkconfiguration = {};
Object.entries(this._config!).forEach(([key, value]) => {
networkconfiguration[key] = value ?? null;
});
@@ -214,7 +206,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._markDirtyStateClean();
this._configHasChanged = false;
const eventdata = {
success: true,
response: undefined,
@@ -261,22 +261,15 @@ export class HaConfigAppsInstalled extends LitElement {
}
.search {
display: flex;
align-items: center;
width: 100%;
height: 56px;
position: sticky;
top: 0;
z-index: 2;
background-color: var(--primary-background-color);
padding: 0 var(--ha-space-4);
box-sizing: border-box;
border-bottom: 1px solid var(--divider-color);
}
ha-input-search {
flex: 1;
min-width: 0;
padding: var(--ha-space-3) var(--ha-space-2);
background: var(--sidebar-background-color);
border-bottom: 1px solid var(--divider-color);
}
.content {
@@ -3,7 +3,6 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/ha-alert";
@@ -55,20 +54,9 @@ const SENSOR_DOMAINS = ["sensor"];
const TEMPERATURE_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_TEMPERATURE];
const HUMIDITY_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_HUMIDITY];
interface AreaFormState {
name: string;
aliases: string[];
labels: string[];
picture: string | null;
icon: string | null;
floor: string | null;
temperatureEntity: string | null;
humidityEntity: string | null;
}
@customElement("dialog-area-registry-detail")
class DialogAreaDetail
extends DirtyStateProviderMixin<AreaFormState>()(LitElement)
extends LitElement
implements HassDialog<AreaRegistryDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -128,23 +116,9 @@ class DialogAreaDetail
this._humidityEntity = null;
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
await this.updateComplete;
}
private _currentState(): AreaFormState {
return {
name: this._name,
aliases: this._aliases,
labels: this._labels,
picture: this._picture,
icon: this._icon,
floor: this._floor,
temperatureEntity: this._temperatureEntity,
humidityEntity: this._humidityEntity,
};
}
public closeDialog(): boolean {
this._open = false;
return true;
@@ -352,8 +326,6 @@ class DialogAreaDetail
if (processed.floor) {
this._floor = processed.floor;
}
this._updateDirtyState(this._currentState());
}
protected render() {
@@ -371,7 +343,7 @@ class DialogAreaDetail
header-title=${entry
? this.hass.localize("ui.panel.config.areas.editor.update_area")
: this.hass.localize("ui.panel.config.areas.editor.create_area")}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<ha-suggest-with-ai-button
@@ -412,9 +384,7 @@ class DialogAreaDetail
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid ||
this._submitting ||
(!!this._params?.entry && !this.isDirtyState)}
.disabled=${nameInvalid || this._submitting}
>
${entry
? this.hass.localize("ui.common.save")
@@ -448,43 +418,36 @@ class DialogAreaDetail
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._updateDirtyState(this._currentState());
}
private _floorChanged(ev) {
this._error = undefined;
this._floor = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _iconChanged(ev) {
this._error = undefined;
this._icon = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _labelsChanged(ev) {
this._error = undefined;
this._labels = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
this._updateDirtyState(this._currentState());
}
private _aliasesChanged(ev: CustomEvent): void {
this._aliases = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _sensorChanged(ev: CustomEvent): void {
const deviceClass = (ev.target as HaEntityPicker).includeDeviceClasses![0];
const key = `_${deviceClass}Entity`;
this[key] = ev.detail.value || null;
this._updateDirtyState(this._currentState());
}
private async _updateEntry() {
@@ -506,7 +469,6 @@ class DialogAreaDetail
} else {
await this._params!.updateEntry!(values);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error =
+290 -300
View File
@@ -34,7 +34,6 @@ import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { groupBy } from "../../../common/util/group-by";
import { afterNextRender } from "../../../common/util/render-status";
import { createColumnsController } from "../../../common/util/responsive-columns";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
@@ -140,8 +139,6 @@ const NAVIGATION_ACTIONS: {
},
] as const;
const MAX_COLUMNS = 3;
@customElement("ha-config-area-page")
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -162,8 +159,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
private _logbookTime = { recent: 86400 };
private _columnsController = createColumnsController(this, MAX_COLUMNS);
private _memberships = memoizeOne(
(
areaId: string,
@@ -293,28 +288,29 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
Object.values(this.hass.devices),
this._entityReg
);
const { devices, entities } = memberships;
const quickLinkCounts = this._getQuickLinkCounts(
memberships,
this._related
);
// Compute the display names on shallow copies so we can sort and render by
// them without mutating the shared registry objects.
const devices = memberships.devices.map((entry) => ({
...entry,
name: computeDeviceNameDisplay(
entry,
this.hass.localize,
this.hass.states
),
}));
sortDeviceRegistryByName(devices, this.hass.locale.language);
const entities = memberships.entities.map((entry) => ({
...entry,
name: computeEntityRegistryName(this.hass, entry),
}));
sortEntityRegistryByName(entities, this.hass.locale.language);
// Pre-compute the entity and device names, so we can sort by them
if (devices) {
devices.forEach((entry) => {
entry.name = computeDeviceNameDisplay(
entry,
this.hass.localize,
this.hass.states
);
});
sortDeviceRegistryByName(devices, this.hass.locale.language);
}
if (entities) {
entities.forEach((entry) => {
entry.name = computeEntityRegistryName(this.hass, entry);
});
sortEntityRegistryByName(entities, this.hass.locale.language);
}
// Group entities by domain
const groupedEntities = groupBy(entities, (entity) =>
@@ -362,267 +358,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
)
);
const infoColumn = html`
${area.picture
? html`<div class="img-container">
<img alt=${area.name} src=${area.picture} />
<ha-icon-button
.path=${mdiPencil}
.entry=${area}
@click=${this._showSettings}
.label=${this.hass.localize(
"ui.panel.config.areas.edit_settings"
)}
class="img-edit-btn"
></ha-icon-button>
</div>`
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>`
: nothing}
</div>`}
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
>${devices.length
? html`<ha-list>
${devices.map(
(device) => html`
<a href="/config/devices/device/${device.id}">
<ha-list-item hasMeta>
<span>${device.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`
)}
</ha-list>`
: html`
<div class="no-entries">
${this.hass.localize("ui.panel.config.devices.no_devices")}
</div>
`}
</ha-card>
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.areas.editor.linked_entities_caption"
)}
>
${nonAutomatedEntities.length
? html`<ha-list>
${nonAutomatedEntities.map(
(entity) => html`
<ha-list-item
@click=${this._openEntity}
.entity=${entity}
hasMeta
>
<span>${entity.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
)}</ha-list
>`
: html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.areas.editor.no_linked_entities"
)}
</div>
`}
</ha-card>
`;
const relatedColumn = html`
${isComponentLoaded(this.hass.config, "automation")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
>
${groupedAutomations?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
${groupedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
: ""}
${relatedAutomations?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
${relatedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
: ""}
${!groupedAutomations?.length && !relatedAutomations?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.automation.no_automations"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass.config, "scene")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
>
${groupedScenes?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
${groupedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
: ""}
${relatedScenes?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
${relatedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
: ""}
${!groupedScenes?.length && !relatedScenes?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.scene.no_scenes"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass.config, "script")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
>
${groupedScripts?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
${groupedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
: ""}
${relatedScripts?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
${relatedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
: ""}
${!groupedScripts?.length && !relatedScripts?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.script.no_scripts"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
`;
const logbookColumn = html`
${isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card outlined .header=${this.hass.localize("panel.logbook")}>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._allEntities(memberships)}
.deviceIds=${this._allDeviceIds(memberships.devices)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: ""}
`;
// In 2 columns the logbook goes on the right, under the shorter
// automations/scenes/scripts column, to balance the column heights.
const columns =
this._columnsController.value ?? (this.narrow ? 1 : MAX_COLUMNS);
const columnContents =
columns >= 3
? [[infoColumn], [relatedColumn], [logbookColumn]]
: columns === 2
? [[infoColumn], [relatedColumn, logbookColumn]]
: [[infoColumn, relatedColumn, logbookColumn]];
return html`
<hass-subpage
.hass=${this.hass}
@@ -667,10 +402,266 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
</ha-dropdown-item>
</ha-dropdown>
<div class="container" ${this._columnsController.target()}>
${columnContents.map(
(contents) => html`<div class="column">${contents}</div>`
)}
<div class="container">
<div class="column">
${area.picture
? html`<div class="img-container">
<img alt=${area.name} src=${area.picture} />
<ha-icon-button
.path=${mdiPencil}
.entry=${area}
@click=${this._showSettings}
.label=${this.hass.localize(
"ui.panel.config.areas.edit_settings"
)}
class="img-edit-btn"
></ha-icon-button>
</div>`
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon
.path=${mdiImagePlus}
slot="start"
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.areas.add_picture"
)}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon
slot="start"
.path=${mdiPlus}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>`
: nothing}
</div>`}
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
>${devices.length
? html`<ha-list>
${devices.map(
(device) => html`
<a href="/config/devices/device/${device.id}">
<ha-list-item hasMeta>
<span>${device.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`
)}
</ha-list>`
: html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.no_devices"
)}
</div>
`}
</ha-card>
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.areas.editor.linked_entities_caption"
)}
>
${nonAutomatedEntities.length
? html`<ha-list>
${nonAutomatedEntities.map(
(entity) => html`
<ha-list-item
@click=${this._openEntity}
.entity=${entity}
hasMeta
>
<span>${entity.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
)}</ha-list
>`
: html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.areas.editor.no_linked_entities"
)}
</div>
`}
</ha-card>
</div>
<div class="column">
${isComponentLoaded(this.hass.config, "automation")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
>
${groupedAutomations?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
${groupedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
: ""}
${relatedAutomations?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
${relatedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
: ""}
${!groupedAutomations?.length && !relatedAutomations?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.automation.no_automations"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass.config, "scene")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
>
${groupedScenes?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
${groupedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
: ""}
${relatedScenes?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
${relatedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
: ""}
${!groupedScenes?.length && !relatedScenes?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.scene.no_scenes"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass.config, "script")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
>
${groupedScripts?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
${groupedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
: ""}
${relatedScripts?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
${relatedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
: ""}
${!groupedScripts?.length && !relatedScripts?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.script.no_scripts"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
</div>
<div class="column">
${isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card
outlined
.header=${this.hass.localize("panel.logbook")}
>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._allEntities(memberships)}
.deviceIds=${this._allDeviceIds(memberships.devices)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: ""}
</div>
</div>
</hass-subpage>
`;
@@ -914,31 +905,30 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
width: 100%;
}
:host {
display: block;
}
.container {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-4);
margin: auto;
max-width: 1280px;
box-sizing: border-box;
padding: var(--ha-space-2) var(--ha-space-4);
margin-top: var(--ha-space-8);
margin-bottom: var(--ha-space-8);
max-width: 1000px;
margin-top: 32px;
margin-bottom: 32px;
}
.column {
padding: 8px;
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
width: 33%;
flex-grow: 1;
}
.fullwidth {
padding: var(--ha-space-2);
padding: 8px;
width: 100%;
}
.column > *:not(:first-child) {
margin-top: var(--ha-space-4);
margin-top: 16px;
}
:host([narrow]) .column {
width: 100%;
}
:host([narrow]) .container {
@@ -18,22 +18,13 @@ import {
} from "../../../../data/automation";
import { MODES, isMaxMode } from "../../../../data/script";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import type { AutomationModeDialog } from "./show-dialog-automation-mode";
interface AutomationModeState {
mode: (typeof MODES)[number];
max?: number;
}
@customElement("ha-dialog-automation-mode")
class DialogAutomationMode
extends DirtyStateProviderMixin<AutomationModeState>()(LitElement)
implements HassDialog
{
class DialogAutomationMode extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -51,10 +42,6 @@ class DialogAutomationMode
this._newMax = isMaxMode(this._newMode)
? params.config.max || AUTOMATION_DEFAULT_MAX
: undefined;
this._initDirtyTracking(
{ type: "shallow" },
{ mode: this._newMode, max: this._newMax }
);
}
public closeDialog(): boolean {
@@ -83,7 +70,6 @@ class DialogAutomationMode
<ha-dialog
.open=${this._open}
header-title=${title}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-icon-button
@@ -137,11 +123,7 @@ class DialogAutomationMode
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._save}>
${this.hass.localize(
"ui.panel.config.automation.editor.change_mode"
)}
@@ -159,7 +141,6 @@ class DialogAutomationMode
} else if (!this._newMax) {
this._newMax = AUTOMATION_DEFAULT_MAX;
}
this._updateDirtyState({ mode: this._newMode, max: this._newMax });
}
private _valueChanged(ev: InputEvent) {
@@ -167,7 +148,6 @@ class DialogAutomationMode
const target = ev.target as HaInput;
if (target.name === "max") {
this._newMax = Number(target.value);
this._updateDirtyState({ mode: this._newMode, max: this._newMax });
}
}
@@ -25,7 +25,6 @@ import type { GenDataTaskResult } from "../../../../data/ai_task";
import type { AutomationConfig } from "../../../../data/automation";
import type { ScriptConfig } from "../../../../data/script";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import {
@@ -39,18 +38,8 @@ import type {
SaveDialogParams,
} from "./show-dialog-automation-save";
interface AutomationSaveState {
name?: string;
description?: string;
icon?: string;
entryUpdates: EntityRegistryUpdate;
}
@customElement("ha-dialog-automation-save")
class DialogAutomationSave
extends DirtyStateProviderMixin<AutomationSaveState>()(LitElement)
implements HassDialog
{
class DialogAutomationSave extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -92,16 +81,6 @@ class DialogAutomationSave
this._entryUpdates.labels.length > 0 ? "labels" : "",
this._entryUpdates.area ? "area" : "",
].filter(Boolean);
this._initDirtyTracking(
{ type: "deep" },
{
name: this._newName,
description: this._newDescription,
icon: this._newIcon,
entryUpdates: this._entryUpdates,
}
);
}
public closeDialog(): boolean {
@@ -273,7 +252,6 @@ class DialogAutomationSave
.open=${this._open}
@closed=${this._dialogClosed}
header-title=${this._params.title || title}
.preventScrimClose=${this.isDirtyState}
>
${this._params.hideInputs
? nothing
@@ -303,11 +281,7 @@ class DialogAutomationSave
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!!this._params.config.alias && !this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._save}>
${this.hass.localize(
this._params.config.alias && !this._params.onDiscard
? "ui.panel.config.automation.editor.rename"
@@ -325,28 +299,17 @@ class DialogAutomationSave
this._visibleOptionals = [...this._visibleOptionals, option];
}
private _trackDirtyState() {
this._updateDirtyState({
name: this._newName,
description: this._newDescription,
icon: this._newIcon,
entryUpdates: this._entryUpdates,
});
}
private _registryEntryChanged(ev) {
ev.stopPropagation();
const id: string = ev.target.id;
const value = ev.detail.value;
this._entryUpdates = { ...this._entryUpdates, [id]: value };
this._trackDirtyState();
}
private _iconChanged(ev: CustomEvent) {
ev.stopPropagation();
this._newIcon = ev.detail.value || undefined;
this._trackDirtyState();
}
private _valueChanged(ev: CustomEvent) {
@@ -357,7 +320,6 @@ class DialogAutomationSave
} else {
this._newName = target.value;
}
this._trackDirtyState();
}
private _handleDiscard() {
@@ -425,7 +387,6 @@ class DialogAutomationSave
this._visibleOptionals = [...this._visibleOptionals, "labels"];
}
}
this._trackDirtyState();
}
private async _save(): Promise<void> {
@@ -1,18 +1,13 @@
import { consume } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-markdown";
import type { BlueprintAutomationConfig } from "../../../data/automation";
import { fetchBlueprints } from "../../../data/blueprint";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import { HaBlueprintGenericEditor } from "../blueprint/blueprint-generic-editor";
import { saveFabStyles } from "./styles";
@@ -24,9 +19,7 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
@property({ type: Boolean }) public saving = false;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext;
@property({ type: Boolean }) public dirty = false;
protected get _config(): BlueprintAutomationConfig {
return this.config;
@@ -65,7 +58,7 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
<ha-button
slot="fab"
size="l"
class=${this._dirtyState?.isDirty ? "dirty" : ""}
class=${this.dirty ? "dirty" : ""}
.disabled=${this.saving}
@click=${this._saveAutomation}
>
@@ -164,9 +164,6 @@ export class HaPlatformCondition extends LitElement {
<ha-icon-button
.path=${mdiHelpCircleOutline}
class="help-icon"
.label=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
></ha-icon-button>
</a>`
: nothing}
+5 -13
View File
@@ -4,25 +4,17 @@ import { showToast } from "../../../util/toast";
export const EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET = 60;
// Editor elements that expose dirty tracking: the top-level automation/script
// editors via `isDirtyState`, and the manual editors via `dirty`.
interface DirtyStateElement extends HTMLElement {
isDirtyState?: boolean;
dirty?: boolean;
}
const isDirtyStateElement = (el: HTMLElement | null): el is DirtyStateElement =>
el !== null && ("isDirtyState" in el || "dirty" in el);
function editorSaveFabVisibleFrom(el: HTMLElement): boolean {
if (
el.localName === "ha-automation-editor" ||
el.localName === "ha-script-editor"
) {
return isDirtyStateElement(el) && Boolean(el.isDirtyState);
return Boolean((el as { dirty?: boolean }).dirty);
}
const holder = closestWithProperty(el, "dirty", false);
return isDirtyStateElement(holder) && Boolean(holder.dirty);
const holder = closestWithProperty(el, "dirty", false) as
| (HTMLElement & { dirty?: boolean })
| null;
return Boolean(holder?.dirty);
}
export function showEditorToast(
@@ -146,10 +146,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
currentConfig: () => this.config!,
});
public override get isDirtyState(): boolean {
return super.isDirtyState || !!this.yamlErrors;
}
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
@@ -425,6 +421,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
.config=${this.config}
.disabled=${this.readOnly}
.saving=${this.saving}
.dirty=${this.dirty}
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
></blueprint-automation-editor>
@@ -437,6 +434,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
.stateObj=${stateObj}
.config=${this.config}
.disabled=${this.readOnly}
.dirty=${this.dirty}
.saving=${this.saving}
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
@@ -556,7 +554,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
<ha-button
slot="fab"
size="l"
class=${this.isDirtyState ? "dirty" : ""}
class=${this.dirty ? "dirty" : ""}
.disabled=${this.saving}
@click=${this._handleSaveAutomation}
>
@@ -604,6 +602,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
!this.entityId
) {
const initData = getAutomationEditorInitData();
this.dirty = !!initData;
let baseConfig: Partial<AutomationConfig> = { description: "" };
if (!initData || !("use_blueprint" in initData)) {
baseConfig = {
@@ -618,8 +617,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
...baseConfig,
...(initData ? normalizeAutomationConfig(initData) : initData),
} as AutomationConfig;
this._initDirtyTracking({ type: "deep" }, baseConfig as AutomationConfig);
this._updateDirtyState(this.config);
this.currentEntityId = undefined;
this.readOnly = false;
}
@@ -627,10 +624,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
if (changedProps.has("entityId") && this.entityId) {
getAutomationStateConfig(this.hass, this.entityId).then((c) => {
this.config = normalizeAutomationConfig(c.config);
this._initDirtyTracking({ type: "deep" }, this.config);
this._checkValidation();
});
this.currentEntityId = this.entityId;
this.dirty = false;
this.readOnly = true;
}
@@ -693,7 +690,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
if (this.readOnly) {
return;
}
this._updateDirtyState(this.config);
this.dirty = true;
this.errors = undefined;
}
@@ -765,6 +762,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _yamlChanged(ev: CustomEvent) {
ev.stopPropagation();
this.dirty = true;
if (!ev.detail.isValid) {
this.yamlErrors = ev.detail.errorMsg;
return;
@@ -774,12 +772,11 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
id: this.config?.id,
...normalizeAutomationConfig(ev.detail.value),
};
this._updateDirtyState(this.config!);
this.errors = undefined;
}
protected async confirmUnsavedChanged(): Promise<boolean> {
if (!this.isDirtyState) {
if (!this.dirty) {
return true;
}
@@ -790,7 +787,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(this.config);
this.dirty = true;
this.requestUpdate();
const id = this.automationId || String(Date.now());
@@ -904,7 +901,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(this.config);
this.dirty = true;
this.requestUpdate();
resolve(true);
},
@@ -921,7 +918,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config!,
updateConfig: (config) => {
this.config = config;
this._updateDirtyState(config);
this.dirty = true;
this.requestUpdate();
resolve();
},
@@ -1012,7 +1009,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
}
}
this._markDirtyStateClean();
this.dirty = false;
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showEditorToast(this, {
@@ -1071,7 +1068,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _applyUndoRedo(config: AutomationConfig) {
this._manualEditor?.triggerCloseSidebar();
this.config = config;
this._updateDirtyState(this.config);
this.dirty = true;
}
private _undo() {
@@ -251,13 +251,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
if (filteredAutomations === null) {
return [];
}
// Build lookups once instead of scanning the registries for every row.
const entityRegLookup = new Map(
entityReg.map((reg) => [reg.entity_id, reg])
);
const labelLookup = labelReg
? new Map(labelReg.map((label) => [label.label_id, label]))
: undefined;
return (
filteredAutomations
? automations.filter((automation) =>
@@ -265,11 +258,13 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
)
: automations
).map((automation) => {
const entityRegEntry = entityRegLookup.get(automation.entity_id);
const entityRegEntry = entityReg.find(
(reg) => reg.entity_id === automation.entity_id
);
const category = entityRegEntry?.categories.automation;
const labels = labelReg && entityRegEntry?.labels;
const label_entries = (labels || [])
.map((lbl) => labelLookup!.get(lbl)!)
.map((lbl) => labelReg!.find((label) => label.label_id === lbl)!)
.filter(Boolean);
const assistants = getEntityVoiceAssistantsIds(
entityReg,
@@ -20,7 +20,6 @@ import {
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import type { Constructor, HomeAssistant, Route } from "../../../types";
import type { EntityRegistryUpdate } from "./automation-save-dialog/show-dialog-automation-save";
@@ -88,9 +87,7 @@ export interface EditorDomainHooks<TConfig> {
export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
superClass: Constructor<LitElement>
) => {
class AutomationScriptEditorClass extends DirtyStateProviderMixin<TConfig>()(
superClass
) {
class AutomationScriptEditorClass extends superClass {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@@ -105,6 +102,8 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
@consume({ context: fullEntitiesContext, subscribe: true })
entityRegistry?: EntityRegistryEntry[];
@state() protected dirty = false;
@state() protected errors?: string;
@state() protected yamlErrors?: string;
@@ -218,9 +217,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected takeControlSave() {
this.readOnly = false;
// Force dirty: set baseline to null so current config always differs
this._initDirtyTracking({ type: "deep" }, null as unknown as TConfig);
this._updateDirtyState(this.config!);
this.dirty = true;
this.blueprintConfig = undefined;
}
@@ -240,6 +237,10 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
}
};
protected get isDirty() {
return this.dirty;
}
protected async promptDiscardChanges() {
return this.confirmUnsavedChanged();
}
@@ -258,9 +259,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
const domain = hooks.domain;
try {
const config = await hooks.fetchFileConfig(this.hass, id);
this.dirty = false;
this.readOnly = false;
this.config = hooks.normalizeConfig(config);
this._initDirtyTracking({ type: "deep" }, this.config);
hooks.checkValidation();
} catch (err: any) {
if (err.status_code !== 404) {
@@ -1,4 +1,3 @@
import { consume } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import {
html,
@@ -20,10 +19,6 @@ import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import type { SidebarConfig } from "../../../data/automation";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import type {
Constructor,
HomeAssistant,
@@ -51,13 +46,7 @@ export const ManualEditorMixin = <TConfig>(
@property({ attribute: false }) public config!: TConfig;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext;
protected get dirty(): boolean {
return this._dirtyState?.isDirty ?? false;
}
@property({ attribute: false }) public dirty = false;
@state() protected pastedConfig?: TConfig;
@@ -55,9 +55,6 @@ export class HaConversationTrigger
@click=${this._removeOption}
slot="end"
.path=${mdiClose}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.conversation.delete"
)}
></ha-icon-button>
</ha-input>
`
@@ -81,9 +78,6 @@ export class HaConversationTrigger
@click=${this._addOption}
slot="end"
.path=${mdiPlus}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.conversation.add_sentence"
)}
></ha-icon-button>
</ha-input>`;
}
@@ -201,9 +201,6 @@ export class HaPlatformTrigger extends LitElement {
<ha-icon-button
.path=${mdiHelpCircleOutline}
class="help-icon"
.label=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
></ha-icon-button>
</a>`
: nothing}
@@ -15,7 +15,6 @@ import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/item/ha-row-item";
import "../../../../components/list/ha-list-base";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import type {
BackupConfig,
BackupMutableConfig,
@@ -83,10 +82,7 @@ const RECOMMENDED_CONFIG: BackupConfig = {
};
@customElement("ha-dialog-backup-onboarding")
class DialogBackupOnboarding
extends DirtyStateProviderMixin<BackupConfig>()(LitElement)
implements HassDialog
{
class DialogBackupOnboarding extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -119,7 +115,6 @@ class DialogBackupOnboarding
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._config!);
}
public closeDialog() {
@@ -174,7 +169,6 @@ class DialogBackupOnboarding
try {
await this._save(true);
this._params?.submit!(true);
this._markDirtyStateClean();
this.closeDialog();
} catch (err) {
// eslint-disable-next-line no-console
@@ -220,7 +214,7 @@ class DialogBackupOnboarding
<ha-dialog
.open=${this._open}
header-title=${this._stepTitle}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${isFirstStep
@@ -299,7 +293,6 @@ class DialogBackupOnboarding
password: this._config.create_backup.password,
},
};
this._updateDirtyState(this._config);
this._done();
}
@@ -522,7 +515,6 @@ class DialogBackupOnboarding
include_addons: data.include_addons || null,
},
};
this._updateDirtyState(this._config);
}
private _scheduleChanged(ev) {
@@ -532,7 +524,6 @@ class DialogBackupOnboarding
schedule: value.schedule,
retention: value.retention,
};
this._updateDirtyState(this._config);
}
private _agentsConfigChanged(ev) {
@@ -544,7 +535,6 @@ class DialogBackupOnboarding
agent_ids: agents,
},
};
this._updateDirtyState(this._config);
}
static get styles(): CSSResultGroup {
@@ -12,17 +12,13 @@ import {
getPreferredAgentForDownload,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { downloadBackupFile } from "../helper/download_backup";
import type { DownloadDecryptedBackupDialogParams } from "./show-dialog-download-decrypted-backup";
@customElement("ha-dialog-download-decrypted-backup")
class DialogDownloadDecryptedBackup
extends DirtyStateProviderMixin<string>()(LitElement)
implements HassDialog
{
class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -36,7 +32,6 @@ class DialogDownloadDecryptedBackup
public showDialog(params: DownloadDecryptedBackupDialogParams): void {
this._open = true;
this._params = params;
this._initDirtyTracking({ type: "shallow" }, "");
}
public closeDialog() {
@@ -65,7 +60,7 @@ class DialogDownloadDecryptedBackup
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.title"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<p>
@@ -110,11 +105,7 @@ class DialogDownloadDecryptedBackup
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._submit}
.disabled=${!this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._submit}>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download"
)}
@@ -145,7 +136,6 @@ class DialogDownloadDecryptedBackup
this._agentId,
this._encryptionKey
);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
if (err?.code === "password_incorrect") {
@@ -165,7 +155,6 @@ class DialogDownloadDecryptedBackup
private _keyChanged(ev) {
this._encryptionKey = ev.currentTarget.value;
this._error = "";
this._updateDirtyState(this._encryptionKey);
}
private get _agentId() {
@@ -28,7 +28,6 @@ import {
fetchBackupConfig,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import "../components/config/ha-backup-config-data";
@@ -60,10 +59,7 @@ const STEPS = ["data", "sync"] as const;
const DISALLOWED_AGENTS_NO_HA = [CLOUD_AGENT];
@customElement("ha-dialog-generate-backup")
class DialogGenerateBackup
extends DirtyStateProviderMixin<FormData>()(LitElement)
implements HassDialog
{
class DialogGenerateBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _step?: "data" | "sync";
@@ -83,8 +79,6 @@ class DialogGenerateBackup
this._formData = INITIAL_DATA;
this._params = _params;
this._open = true;
this._initDirtyTracking({ type: "deep" }, INITIAL_DATA);
this._updateDirtyState(this._formData);
this._fetchAgents();
this._fetchBackupConfig();
@@ -166,7 +160,6 @@ class DialogGenerateBackup
agents_mode: "custom",
agent_ids: filteredAgents,
};
this._updateDirtyState(this._formData);
}
}
}
@@ -187,11 +180,7 @@ class DialogGenerateBackup
const selectedAgents = this._formData.agent_ids;
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-dialog .open=${this._open} @closed=${this._dialogClosed}>
<ha-dialog-header slot="header">
${isFirstStep
? html`
@@ -287,7 +276,6 @@ class DialogGenerateBackup
...this._formData!,
data,
};
this._updateDirtyState(this._formData);
}
private _renderSync() {
@@ -382,7 +370,6 @@ class DialogGenerateBackup
...this._formData!,
agents_mode: value,
};
this._updateDirtyState(this._formData);
}
private _agentsChanged(ev) {
@@ -390,7 +377,6 @@ class DialogGenerateBackup
...this._formData!,
agent_ids: ev.detail.value,
};
this._updateDirtyState(this._formData);
}
private _nameChanged(ev: InputEvent) {
@@ -398,7 +384,6 @@ class DialogGenerateBackup
...this._formData!,
name: (ev.target as HaInput).value ?? "",
};
this._updateDirtyState(this._formData);
}
private _disabledAgentIds() {
@@ -444,7 +429,6 @@ class DialogGenerateBackup
}
this._params!.submit?.(params);
this._markDirtyStateClean();
this.closeDialog();
}
@@ -13,7 +13,6 @@ import type {
} from "../../../../components/ha-form/types";
import { extractApiErrorMessage } from "../../../../data/hassio/common";
import { changeMountOptions } from "../../../../data/supervisor/mounts";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { LocalBackupLocationDialogParams } from "./show-dialog-local-backup-location";
@@ -26,14 +25,8 @@ const SCHEMA = [
},
] as const satisfies HaFormSchema[];
interface LocalBackupLocationFormState {
default_backup_mount: string | null | undefined;
}
@customElement("dialog-local-backup-location")
class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocationFormState>()(
LitElement
) {
class LocalBackupLocationDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _dialogParams?: LocalBackupLocationDialogParams;
@@ -51,10 +44,6 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
): Promise<void> {
this._dialogParams = dialogParams;
this._open = true;
this._initDirtyTracking(
{ type: "shallow" },
{ default_backup_mount: undefined }
);
}
public closeDialog(): void {
@@ -79,7 +68,7 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
header-title=${this.hass.localize(
`ui.panel.config.backup.dialogs.local_backup_location.title`
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error
@@ -113,7 +102,7 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
.disabled=${this._waiting || !this.isDirtyState}
.disabled=${this._waiting || !this._data}
slot="primaryAction"
@click=${this._changeMount}
>
@@ -136,9 +125,6 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
this._data = {
default_backup_mount: newLocation === "/backup" ? null : newLocation,
};
this._updateDirtyState({
default_backup_mount: this._data.default_backup_mount,
});
}
private async _changeMount() {
@@ -154,7 +140,6 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
this._waiting = false;
return;
}
this._markDirtyStateClean();
this.closeDialog();
}
@@ -52,6 +52,7 @@ class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
)}
prevent-scrim-close
@closed=${this.closeDialog}
>
<ha-icon-button
@@ -21,7 +21,6 @@ import {
type BackupUploadFileFormData,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
@@ -29,7 +28,7 @@ import type { UploadBackupDialogParams } from "./show-dialog-upload-backup";
@customElement("ha-dialog-upload-backup")
export class DialogUploadBackup
extends DirtyStateProviderMixin<BackupUploadFileFormData>()(LitElement)
extends LitElement
implements HassDialog<UploadBackupDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -48,8 +47,6 @@ export class DialogUploadBackup
this._params = params;
this._formData = INITIAL_UPLOAD_FORM_DATA;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, INITIAL_UPLOAD_FORM_DATA);
this._updateDirtyState(this._formData);
}
private _dialogClosed() {
@@ -67,6 +64,10 @@ export class DialogUploadBackup
return true;
}
private _formValid() {
return this._formData?.file !== undefined;
}
protected render() {
if (!this._params || !this._formData) {
return nothing;
@@ -78,7 +79,7 @@ export class DialogUploadBackup
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.title"
)}
.preventScrimClose=${this.isDirtyState || this._uploading}
?prevent-scrim-close=${this._uploading}
@closed=${this._dialogClosed}
>
${this._error
@@ -111,7 +112,7 @@ export class DialogUploadBackup
<ha-button
slot="primaryAction"
@click=${this._upload}
.disabled=${!this.isDirtyState || this._uploading}
.disabled=${!this._formValid() || this._uploading}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.action"
@@ -130,13 +131,11 @@ export class DialogUploadBackup
...this._formData!,
file,
};
this._updateDirtyState(this._formData);
}
private _filesCleared() {
this._error = undefined;
this._formData = INITIAL_UPLOAD_FORM_DATA;
this._updateDirtyState(this._formData);
}
private async _upload() {
@@ -162,7 +161,6 @@ export class DialogUploadBackup
try {
await uploadBackup(this.hass, file, agentIds);
this._params!.submit?.();
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -84,10 +84,7 @@ export class CloudRegister extends LitElement {
${this.hass.localize(
"ui.panel.config.cloud.register.information3"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
<a href="https://www.nabucasa.com" target="_blank"
>Nabu&nbsp;Casa,&nbsp;Inc</a
>
${this.hass.localize(
@@ -45,7 +45,6 @@ import {
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import "../dashboard/ha-config-updates";
import { showJoinBetaDialog } from "./updates/show-dialog-join-beta";
@@ -348,22 +347,13 @@ class HaConfigSectionUpdates extends LitElement {
return;
}
try {
await installUpdates(this.hass, entityIds, false);
await installUpdates(this.hass, entityIds);
} catch (err: any) {
let message = extractApiErrorMessage(err);
// The backend error embeds the raw entity_id; swap in the update's name.
for (const entityId of entityIds) {
const stateObj = this.hass.states[entityId] as UpdateEntity | undefined;
if (stateObj && message.includes(entityId)) {
message = message.replaceAll(
entityId,
stateObj.attributes.title ||
stateObj.attributes.friendly_name ||
entityId
);
}
}
showToast(this, { message, duration: 10000, dismissable: true });
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.updates.update_all_failed"),
text: extractApiErrorMessage(err),
warning: true,
});
}
}
@@ -17,6 +17,7 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-menu-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tip";
import "../../../components/ha-tooltip";
@@ -235,6 +236,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
<ha-menu-button slot="navigationIcon"></ha-menu-button>
<div slot="title">${this.hass.localize("panel.config")}</div>
<ha-icon-button
@@ -37,12 +37,6 @@ class HaPanelDevStateRenderer extends LitElement {
@property({ attribute: false })
public showAttributes = true;
@property({ attribute: false })
public showDevice = true;
@property({ attribute: false })
public showArea = true;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -63,15 +57,11 @@ class HaPanelDevStateRenderer extends LitElement {
protected render() {
const showAttributes = !this.narrow && this.showAttributes;
const showDevice = !this.narrow && this.showDevice;
const showArea = !this.narrow && this.showArea;
return html`
<div
class=${classMap({
entities: true,
"hide-attributes": !showAttributes,
"hide-device": !showDevice,
"hide-area": !showArea,
"hide-extra": this.narrow,
})}
role="table"
@@ -91,14 +81,14 @@ class HaPanelDevStateRenderer extends LitElement {
)}
</span>
</div>
<div class="header" role="columnheader" ?hidden=${!showDevice}>
<div class="header" role="columnheader">
<span class="padded">
${this._i18n.localize(
"ui.panel.config.entities.picker.headers.device"
)}
</span>
</div>
<div class="header" role="columnheader" ?hidden=${!showArea}>
<div class="header" role="columnheader">
<span class="padded">
${this._i18n.localize("ui.panel.config.generic.headers.area")}
</span>
@@ -365,24 +355,6 @@ class HaPanelDevStateRenderer extends LitElement {
white-space: pre-wrap;
}
.hide-device .filter-devices {
display: none;
}
.hide-device .row .header:nth-child(3),
.hide-device .row .cell:nth-child(3) {
display: none;
}
.hide-area .filter-areas {
display: none;
}
.hide-area .row .header:nth-child(4),
.hide-area .row .cell:nth-child(4) {
display: none;
}
.hide-attributes .filter-attributes {
display: none;
}
@@ -87,18 +87,6 @@ class HaPanelDevState extends LitElement {
})
private _showAttributes = true;
@storage({
key: "devToolsShowDevice",
state: true,
})
private _showDevice = true;
@storage({
key: "devToolsShowArea",
state: true,
})
private _showArea = true;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state()
@@ -169,32 +157,14 @@ class HaPanelDevState extends LitElement {
)}
</h1>
${!this.narrow
? html`
<div class="filters-toggles">
<ha-checkbox
.checked=${this._showDevice}
@change=${this._saveDeviceCheckboxState}
>
${this._i18n.localize(
"ui.panel.config.entities.picker.headers.device"
)}
</ha-checkbox>
<ha-checkbox
.checked=${this._showArea}
@change=${this._saveAreaCheckboxState}
>
${this._i18n.localize("ui.panel.config.generic.headers.area")}
</ha-checkbox>
</div>
<ha-checkbox
.checked=${this._showAttributes}
@change=${this._saveAttributeCheckboxState}
>
${this._i18n.localize(
"ui.panel.config.developer-tools.tabs.states.attributes"
)}
</ha-checkbox>
`
? html`<ha-checkbox
.checked=${this._showAttributes}
@change=${this._saveAttributeCheckboxState}
>
${this._i18n.localize(
"ui.panel.config.developer-tools.tabs.states.attributes"
)}
</ha-checkbox>`
: nothing}
</div>
<ha-expansion-panel
@@ -310,8 +280,6 @@ class HaPanelDevState extends LitElement {
.entities=${entities}
.virtualize=${entities.length > VIRTUALIZE_THRESHOLD}
.showAttributes=${this._showAttributes}
.showDevice=${this._showDevice}
.showArea=${this._showArea}
@states-tool-entity-selected=${this._entitySelected}
>
<ha-input-search
@@ -625,14 +593,6 @@ class HaPanelDevState extends LitElement {
this._showAttributes = ev.target.checked;
}
private _saveDeviceCheckboxState(ev) {
this._showDevice = ev.target.checked;
}
private _saveAreaCheckboxState(ev) {
this._showArea = ev.target.checked;
}
private _yamlChanged(ev) {
this._stateAttributes = ev.detail.value;
this._validJSON = ev.detail.isValid;
@@ -657,25 +617,12 @@ class HaPanelDevState extends LitElement {
.heading {
display: flex;
justify-content: flex-start;
align-items: center;
gap: var(--ha-space-4);
justify-content: space-between;
}
.heading h1 {
margin-right: auto;
}
.filters-toggles {
display: flex;
align-items: center;
gap: var(--ha-space-4);
}
.heading .filters-toggles ha-checkbox {
margin-right: 0;
width: max-content;
display: inline-flex;
.heading ha-checkbox {
margin-right: var(--ha-space-2);
justify-content: center;
}
.entity-id {
+178 -188
View File
@@ -38,7 +38,6 @@ import { stringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { computeRTL } from "../../../common/util/compute_rtl";
import { groupBy } from "../../../common/util/group-by";
import { createColumnsController } from "../../../common/util/responsive-columns";
import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -176,8 +175,6 @@ export interface DeviceAlert {
const DEVICE_ALERTS_INTERVAL = 30000;
const MAX_COLUMNS = 3;
@customElement("ha-config-device-page")
export class HaConfigDevicePage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -214,8 +211,6 @@ export class HaConfigDevicePage extends LitElement {
private _logbookTime = { recent: 86400 };
private _columnsController = createColumnsController(this, MAX_COLUMNS);
private _integrations = memoizeOne(
(
device: DeviceRegistryEntry,
@@ -754,176 +749,6 @@ export class HaConfigDevicePage extends LitElement {
`
: "";
const infoColumn = html`
${this._deviceAlerts?.length
? html`
<div>
${this._deviceAlerts.map(
(alert) => html`
<ha-alert .alertType=${alert.level}> ${alert.text} </ha-alert>
`
)}
</div>
`
: ""}
<ha-device-info-card .hass=${this.hass} .device=${device}>
${deviceInfo}
${firstDeviceAction || actions.length
? html`
<div class="card-actions" slot="actions">
<ha-button
href=${ifDefined(firstDeviceAction!.href)}
rel=${ifDefined(
firstDeviceAction!.target ? "noreferrer" : undefined
)}
appearance="plain"
target=${ifDefined(firstDeviceAction!.target)}
class=${ifDefined(firstDeviceAction!.classes)}
.variant=${firstDeviceAction!.classes?.includes("warning")
? "danger"
: "brand"}
.action=${firstDeviceAction!.action}
@click=${this._deviceActionClicked}
>
${firstDeviceAction!.label}
${firstDeviceAction!.icon
? html`
<ha-svg-icon
class=${ifDefined(firstDeviceAction!.classes)}
.path=${firstDeviceAction!.icon}
slot="start"
></ha-svg-icon>
`
: nothing}
${firstDeviceAction!.trailingIcon
? html`
<ha-svg-icon
.path=${firstDeviceAction!.trailingIcon}
slot="end"
></ha-svg-icon>
`
: nothing}
</ha-button>
${actions.length
? html`
<ha-dropdown
@wa-select=${this._deviceActionSelected}
placement="bottom-end"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${actions.map((deviceAction, idx) => {
const dropdownItem = html`<ha-dropdown-item
.value=${idx}
.data=${deviceAction}
.variant=${deviceAction.classes?.includes("warning")
? "danger"
: "default"}
>
${deviceAction.icon
? html`
<ha-svg-icon
.path=${deviceAction.icon}
slot="icon"
></ha-svg-icon>
`
: ""}
${deviceAction.label}
${deviceAction.trailingIcon
? html`
<ha-svg-icon
slot="details"
.path=${deviceAction.trailingIcon}
></ha-svg-icon>
`
: ""}
</ha-dropdown-item>`;
return deviceAction.href
? html`<a
href=${deviceAction.href}
target=${ifDefined(deviceAction.target)}
rel=${ifDefined(
deviceAction.target ? "noreferrer" : undefined
)}
>${dropdownItem}
</a>`
: dropdownItem;
})}
</ha-dropdown>
`
: ""}
</div>
`
: ""}
</ha-device-info-card>
`;
const entitiesColumn = html`
${(
[
"control",
"sensor",
"notify",
"event",
"assist",
"config",
"diagnostic",
] as const
).map((category) =>
// Make sure we render controls if no other cards will be rendered
entitiesByCategory[category].length > 0 ||
(entities.length === 0 && category === "control")
? html`
<ha-device-entities-card
.hass=${this.hass}
.header=${this.hass.localize(
`ui.panel.config.devices.entities.${category}`
)}
.deviceName=${deviceName}
.entities=${entitiesByCategory[category]}
.showHidden=${device.disabled_by !== null}
>
</ha-device-entities-card>
`
: ""
)}
<ha-device-via-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
></ha-device-via-devices-card>
`;
const logbookColumn = isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card outlined>
<h1 class="card-header">${this.hass.localize("panel.logbook")}</h1>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._entityIds(entities)}
.deviceIds=${this._deviceIdInList(this.deviceId)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: nothing;
const columns =
this._columnsController.value ?? (this.narrow ? 1 : MAX_COLUMNS);
const columnContents =
columns >= 3
? [[infoColumn, relatedCard], [entitiesColumn], [logbookColumn]]
: columns === 2
? [[infoColumn, relatedCard, logbookColumn], [entitiesColumn]]
: [[infoColumn, entitiesColumn, relatedCard, logbookColumn]];
return html`<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
@@ -971,7 +796,7 @@ export class HaConfigDevicePage extends LitElement {
</ha-dropdown-item>
</ha-dropdown>
<div class="container" ${this._columnsController.target()}>
<div class="container">
<div class="header fullwidth">
${area
? html`<div class="header-name">
@@ -1024,9 +849,175 @@ export class HaConfigDevicePage extends LitElement {
: ""}
</div>
</div>
${columnContents.map(
(contents) => html`<div class="column">${contents}</div>`
)}
<div class="column">
${this._deviceAlerts?.length
? html`
<div>
${this._deviceAlerts.map(
(alert) => html`
<ha-alert .alertType=${alert.level}>
${alert.text}
</ha-alert>
`
)}
</div>
`
: ""}
<ha-device-info-card .hass=${this.hass} .device=${device}>
${deviceInfo}
${firstDeviceAction || actions.length
? html`
<div class="card-actions" slot="actions">
<ha-button
href=${ifDefined(firstDeviceAction!.href)}
rel=${ifDefined(
firstDeviceAction!.target ? "noreferrer" : undefined
)}
appearance="plain"
target=${ifDefined(firstDeviceAction!.target)}
class=${ifDefined(firstDeviceAction!.classes)}
.variant=${firstDeviceAction!.classes?.includes("warning")
? "danger"
: "brand"}
.action=${firstDeviceAction!.action}
@click=${this._deviceActionClicked}
>
${firstDeviceAction!.label}
${firstDeviceAction!.icon
? html`
<ha-svg-icon
class=${ifDefined(firstDeviceAction!.classes)}
.path=${firstDeviceAction!.icon}
slot="start"
></ha-svg-icon>
`
: nothing}
${firstDeviceAction!.trailingIcon
? html`
<ha-svg-icon
.path=${firstDeviceAction!.trailingIcon}
slot="end"
></ha-svg-icon>
`
: nothing}
</ha-button>
${actions.length
? html`
<ha-dropdown
@wa-select=${this._deviceActionSelected}
placement="bottom-end"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${actions.map((deviceAction, idx) => {
const dropdownItem = html`<ha-dropdown-item
.value=${idx}
.data=${deviceAction}
.variant=${deviceAction.classes?.includes(
"warning"
)
? "danger"
: "default"}
>
${deviceAction.icon
? html`
<ha-svg-icon
.path=${deviceAction.icon}
slot="icon"
></ha-svg-icon>
`
: ""}
${deviceAction.label}
${deviceAction.trailingIcon
? html`
<ha-svg-icon
slot="details"
.path=${deviceAction.trailingIcon}
></ha-svg-icon>
`
: ""}
</ha-dropdown-item>`;
return deviceAction.href
? html`<a
href=${deviceAction.href}
target=${ifDefined(deviceAction.target)}
rel=${ifDefined(
deviceAction.target
? "noreferrer"
: undefined
)}
>${dropdownItem}
</a>`
: dropdownItem;
})}
</ha-dropdown>
`
: ""}
</div>
`
: ""}
</ha-device-info-card>
${!this.narrow ? relatedCard : ""}
</div>
<div class="column">
${(
[
"control",
"sensor",
"notify",
"event",
"assist",
"config",
"diagnostic",
] as const
).map((category) =>
// Make sure we render controls if no other cards will be rendered
entitiesByCategory[category].length > 0 ||
(entities.length === 0 && category === "control")
? html`
<ha-device-entities-card
.hass=${this.hass}
.header=${this.hass.localize(
`ui.panel.config.devices.entities.${category}`
)}
.deviceName=${deviceName}
.entities=${entitiesByCategory[category]}
.showHidden=${device.disabled_by !== null}
>
</ha-device-entities-card>
`
: ""
)}
<ha-device-via-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
></ha-device-via-devices-card>
</div>
<div class="column">
${this.narrow ? relatedCard : ""}
${isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card outlined>
<h1 class="card-header">
${this.hass.localize("panel.logbook")}
</h1>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._entityIds(entities)}
.deviceIds=${this._deviceIdInList(this.deviceId)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: ""}
</div>
</div>
</hass-subpage>`;
}
@@ -1636,17 +1627,11 @@ export class HaConfigDevicePage extends LitElement {
return [
haStyle,
css`
:host {
display: block;
}
.container {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-4);
margin: auto;
max-width: 1280px;
box-sizing: border-box;
padding: var(--ha-space-2) var(--ha-space-4);
max-width: 1000px;
margin-top: var(--ha-space-8);
margin-bottom: var(--ha-space-8);
}
@@ -1707,11 +1692,12 @@ export class HaConfigDevicePage extends LitElement {
.column,
.fullwidth {
padding: var(--ha-space-2);
box-sizing: border-box;
}
.column {
flex: 1 1 0;
min-width: 0;
width: 33%;
flex-grow: 1;
}
.fullwidth {
width: 100%;
@@ -1753,6 +1739,10 @@ export class HaConfigDevicePage extends LitElement {
margin-top: var(--ha-space-4);
}
:host([narrow]) .column {
width: 100%;
}
a {
text-decoration: none;
color: var(--primary-color);
@@ -452,12 +452,6 @@ export class HaConfigDeviceDashboard extends LitElement {
outputDevices = outputDevices.filter((device) => !device.disabled_by);
}
// Build a label lookup once instead of scanning labelReg for every
// label of every device.
const labelLookup = labelReg
? new Map(labelReg.map((label) => [label.label_id, label]))
: undefined;
const formattedOutputDevices = outputDevices.map((device) => {
const deviceEntries = sortConfigEntries(
device.config_entries
@@ -468,7 +462,7 @@ export class HaConfigDeviceDashboard extends LitElement {
const labels = labelReg && device?.labels;
const labelsEntries = (labels || [])
.map((lbl) => labelLookup!.get(lbl))
.map((lbl) => labelReg!.find((label) => label.label_id === lbl))
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
const { areaName } = computeDeviceAreaLabel(
@@ -22,7 +22,6 @@ import {
} from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import "./ha-energy-power-config";
@@ -36,19 +35,13 @@ import {
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
import type { HaInput } from "../../../../components/input/ha-input";
interface BatteryFormState {
source: BatterySourceTypeEnergyPreference;
powerType: PowerType;
powerConfig: PowerConfig;
}
const energyUnitClasses = ["energy"];
const socStatisticsUnits = ["%"];
const socDeviceClass = "battery";
@customElement("dialog-energy-battery-settings")
export class DialogEnergyBatterySettings
extends DirtyStateProviderMixin<BatteryFormState>()(LitElement)
extends LitElement
implements HassDialog<EnergySettingsBatteryDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -115,14 +108,6 @@ export class DialogEnergyBatterySettings
);
this._open = true;
this._initDirtyTracking(
{ type: "deep" },
{
source: this._source!,
powerType: this._powerType,
powerConfig: this._powerConfig,
}
);
}
public closeDialog() {
@@ -152,7 +137,7 @@ export class DialogEnergyBatterySettings
header-title=${this.hass.localize(
"ui.panel.config.energy.battery.dialog.header"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : nothing}
@@ -256,8 +241,7 @@ export class DialogEnergyBatterySettings
</ha-button>
<ha-button
@click=${this._save}
.disabled=${!this._isValid() ||
(!!this._params?.source && !this.isDirtyState)}
.disabled=${!this._isValid()}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
@@ -299,13 +283,11 @@ export class DialogEnergyBatterySettings
private _statisticToChanged(ev: ValueChangedEvent<string>) {
this._source = { ...this._source!, stat_energy_to: ev.detail.value };
this._updateMetadata(ev.detail.value);
this._updateFormDirtyState();
}
private _statisticFromChanged(ev: ValueChangedEvent<string>) {
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
this._updateMetadata(ev.detail.value);
this._updateFormDirtyState();
}
private _nameChanged(ev: InputEvent) {
@@ -316,7 +298,6 @@ export class DialogEnergyBatterySettings
if (!this._source.name) {
delete this._source.name;
}
this._updateFormDirtyState();
}
private _handlePowerConfigChanged(
@@ -324,7 +305,6 @@ export class DialogEnergyBatterySettings
) {
this._powerType = ev.detail.powerType;
this._powerConfig = ev.detail.powerConfig;
this._updateFormDirtyState();
}
private _statisticSocChanged(ev: ValueChangedEvent<string>) {
@@ -332,15 +312,6 @@ export class DialogEnergyBatterySettings
...this._source!,
stat_soc: ev.detail.value || undefined,
};
this._updateFormDirtyState();
}
private _updateFormDirtyState(): void {
this._updateDirtyState({
source: this._source!,
powerType: this._powerType,
powerConfig: this._powerConfig,
});
}
private async _save() {
@@ -364,7 +335,6 @@ export class DialogEnergyBatterySettings
}
await this._params!.saveCallback(source);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;

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