mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-27 15:00:29 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9d8a86905 | |||
| 0f75e1074b | |||
| 300b34f876 | |||
| 1f14f8689c | |||
| 56966ee04b | |||
| 598a987ba5 | |||
| 8775d3f4c5 | |||
| 2771a565b9 | |||
| d3505cece0 | |||
| 8579f77c12 | |||
| 6249e037ed | |||
| 8beb4ee36e | |||
| 58ded99773 | |||
| 0d2b99b3a5 | |||
| 4be7a32148 | |||
| e47cebcf6e | |||
| e8bb029d7e | |||
| 4373f016c9 | |||
| 9820428cd3 | |||
| 4fa09b0085 | |||
| 5d63fad772 | |||
| 7f27c9a948 | |||
| c07264fc27 | |||
| 978ad69c1b | |||
| 047ebd4524 | |||
| a25aa656a3 | |||
| 88f244b9df |
@@ -1,12 +1,18 @@
|
||||
---
|
||||
name: ha-frontend-testing
|
||||
description: Home Assistant frontend validation workflow. Use when running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
|
||||
description: Home Assistant frontend testing and validation workflow. Use when adding or updating tests, running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
|
||||
---
|
||||
|
||||
# HA Frontend Testing
|
||||
|
||||
Use this skill when choosing or running validation for frontend changes.
|
||||
|
||||
## Test Helpers
|
||||
|
||||
- Before adding or changing tests, inspect the relevant suite's existing helpers and fixtures. Reuse them instead of duplicating setup, test data, navigation, interactions, waits, or assertions.
|
||||
- When the same test flow appears more than once, move it into the closest suite-local helper with a focused interface.
|
||||
- Keep one-off test behaviour in the test unless a helper makes the intent materially clearer. Do not hide the behaviour under test behind broad, configurable abstractions.
|
||||
|
||||
## Core Commands
|
||||
|
||||
```bash
|
||||
@@ -35,7 +41,7 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
@@ -43,15 +49,17 @@ Each suite has its own dev server port. Playwright reuses an existing server loc
|
||||
|
||||
Start the relevant suite server, then run that suite:
|
||||
|
||||
| Suite | Server | Test command |
|
||||
| ------- | ------------------------------- | ----------------------- |
|
||||
| App | `yarn test:e2e:app:dev` on 8095 | `yarn test:e2e:app` |
|
||||
| Demo | `yarn dev:demo` on 8090 | `yarn test:e2e:demo` |
|
||||
| Gallery | `yarn dev:gallery` on 8100 | `yarn test:e2e:gallery` |
|
||||
| Suite | Background server | Test command |
|
||||
| ------- | -------------------------------------------- | ----------------------- |
|
||||
| App | `yarn test:e2e:app:dev --background` on 8095 | `yarn test:e2e:app` |
|
||||
| Demo | `yarn dev:demo --background` on 8090 | `yarn test:e2e:demo` |
|
||||
| Gallery | `yarn dev:gallery --background` on 8100 | `yarn test:e2e:gallery` |
|
||||
|
||||
The custom development wrappers use `/__ha_dev_status` to identify and manage their own suites. Playwright server reuse checks the configured URL instead. Wrapper start and stop operations are idempotent for a matching suite and reject an unrelated process occupying the port.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites. Run suites directly; piping through output truncation hides progress and failures.
|
||||
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
|
||||
|
||||
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Prepare dependencies
|
||||
description: Install and cache the complete dependency tree
|
||||
|
||||
inputs:
|
||||
node-modules-cache-key:
|
||||
description: Prefix for the shared node_modules cache key
|
||||
default: node-modules-v1
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check for complete dependency tree
|
||||
id: dependencies
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
lookup-only: true
|
||||
|
||||
- name: Setup Node and install
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache: false
|
||||
|
||||
- name: Save complete dependency tree
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
@@ -8,16 +8,40 @@ inputs:
|
||||
cache:
|
||||
description: Enable the yarn cache in setup-node
|
||||
default: "true"
|
||||
node-modules-cache:
|
||||
description: Restore the exact shared node_modules cache before installing
|
||||
default: "false"
|
||||
node-modules-cache-key:
|
||||
description: Prefix for the shared node_modules cache key
|
||||
default: node-modules-v1
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Restore complete dependency tree
|
||||
id: dependency-cache
|
||||
if: inputs.node-modules-cache == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
cache: ${{ inputs.cache == 'true' && (inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true') && 'yarn' || '' }}
|
||||
|
||||
- name: Enable Corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
if: inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
@@ -21,16 +22,30 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint and check format
|
||||
prepare-dependencies:
|
||||
name: Prepare dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
|
||||
lint:
|
||||
name: Lint and check format
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Check for duplicate dependencies
|
||||
run: yarn dedupe --check
|
||||
- name: Build resources
|
||||
@@ -63,14 +78,17 @@ jobs:
|
||||
run: yarn run lint:licenses
|
||||
test:
|
||||
name: Run tests
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
|
||||
env:
|
||||
@@ -80,6 +98,7 @@ jobs:
|
||||
build:
|
||||
name: Build frontend
|
||||
needs:
|
||||
- prepare-dependencies
|
||||
- lint
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
@@ -88,8 +107,10 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Build Application
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
|
||||
+223
-47
@@ -22,9 +22,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
prepare-dependencies:
|
||||
name: Prepare dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -32,14 +31,51 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
|
||||
prepare-container-dependencies:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
with:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build demo
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
target: build-demo-e2e
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -52,6 +88,7 @@ jobs:
|
||||
# ── Build the e2e test app and share it via artifact ────────────────────────
|
||||
build-e2e-test-app:
|
||||
name: Build e2e test app
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -59,14 +96,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build e2e test app
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-e2e-test-app
|
||||
target: build-e2e-test-app-e2e
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -79,6 +119,7 @@ jobs:
|
||||
# ── Build the gallery and share it via artifact ─────────────────────────────
|
||||
build-gallery:
|
||||
name: Build gallery
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -86,8 +127,10 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build gallery
|
||||
uses: ./.github/actions/build
|
||||
@@ -103,41 +146,39 @@ jobs:
|
||||
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]
|
||||
# ── Run Playwright tests against Chromium ──────────────────────────────────
|
||||
e2e-demo:
|
||||
name: E2E demo (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-demo
|
||||
- prepare-container-dependencies
|
||||
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
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
shardTotal:
|
||||
- 2
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
# Resolve the installed Playwright version so the browser cache tracks
|
||||
# Playwright itself, not every unrelated dependency bump.
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Cache the downloaded browser build keyed on the installed Playwright
|
||||
# version, so re-runs skip the ~170 MB download unless Playwright changes.
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: yarn playwright install --with-deps chromium
|
||||
timeout-minutes: 10
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -145,36 +186,135 @@ jobs:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Run Playwright demo tests
|
||||
run: yarn test:e2e:demo --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload demo blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-demo-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/demo/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
e2e-app:
|
||||
name: E2E app (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-e2e-test-app
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
shardTotal:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Run Playwright app tests
|
||||
run: yarn test:e2e:app --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload app blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-app-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/app/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
e2e-gallery:
|
||||
name: E2E gallery (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-gallery
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
shardTotal:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
|
||||
- name: Run Playwright tests (local)
|
||||
run: yarn test:e2e
|
||||
- name: Run Playwright gallery tests
|
||||
run: yarn test:e2e:gallery --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload blob report
|
||||
- name: Upload gallery blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
name: blob-report-gallery-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/gallery/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
# ── Merge local blob reports and post PR comment ───────────────────────────
|
||||
report:
|
||||
name: Report
|
||||
needs: [e2e-local]
|
||||
needs:
|
||||
- e2e-demo
|
||||
- e2e-app
|
||||
- e2e-gallery
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ always() }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -184,15 +324,31 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Download blob report (local)
|
||||
- name: Download demo blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
pattern: blob-report-demo-*
|
||||
path: test/e2e/reports/demo/
|
||||
|
||||
- name: Download app blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-app-*
|
||||
path: test/e2e/reports/app/
|
||||
|
||||
- name: Download gallery blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-gallery-*
|
||||
path: test/e2e/reports/gallery/
|
||||
|
||||
- name: Stage blobs for merge
|
||||
run: node test/e2e/collect-blob-reports.mjs
|
||||
@@ -209,7 +365,11 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
- name: Post report to PR
|
||||
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
(needs.e2e-demo.result == 'failure' ||
|
||||
needs.e2e-app.result == 'failure' ||
|
||||
needs.e2e-gallery.result == 'failure')
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
@@ -217,3 +377,19 @@ jobs:
|
||||
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
|
||||
);
|
||||
await postReportComment({ github, context, core });
|
||||
|
||||
- name: Check suite results
|
||||
run: |
|
||||
failed=0
|
||||
for suite in \
|
||||
"demo:${{ needs.e2e-demo.result }}" \
|
||||
"app:${{ needs.e2e-app.result }}" \
|
||||
"gallery:${{ needs.e2e-gallery.result }}"; do
|
||||
name="${suite%%:*}"
|
||||
result="${suite#*:}"
|
||||
echo "E2E ${name}: ${result}"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
|
||||
@@ -232,7 +232,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
demo({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
|
||||
return {
|
||||
name: "demo" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -247,6 +247,7 @@ module.exports.config = {
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -306,7 +307,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -321,6 +322,7 @@ module.exports.config = {
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,6 +42,22 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-demo-e2e",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean-demo",
|
||||
// Cast needs to be backwards compatible and older HA has no translations
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-demo",
|
||||
"rspack-prod-demo-e2e",
|
||||
"gen-pages-demo-prod-e2e"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"analyze-demo",
|
||||
gulp.series(
|
||||
|
||||
@@ -39,3 +39,18 @@ gulp.task(
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-e2e-test-app-e2e",
|
||||
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-e2e",
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -225,6 +225,16 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-demo-prod-e2e",
|
||||
genPagesProdTask(
|
||||
DEMO_PAGE_ENTRIES,
|
||||
paths.demo_dir,
|
||||
paths.demo_output_root,
|
||||
paths.demo_output_latest
|
||||
)
|
||||
);
|
||||
|
||||
const GALLERY_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
|
||||
|
||||
gulp.task(
|
||||
|
||||
@@ -177,6 +177,18 @@ gulp.task("rspack-prod-demo", () =>
|
||||
bothBuilds(createDemoConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-demo-e2e", () =>
|
||||
prodBuild(
|
||||
createDemoConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -269,6 +281,18 @@ gulp.task("rspack-prod-e2e-test-app", () =>
|
||||
bothBuilds(createE2eTestAppConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-e2e-test-app-e2e", () =>
|
||||
prodBuild(
|
||||
createE2eTestAppConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -387,9 +387,14 @@ const createAppConfig = ({
|
||||
bundle.config.app({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
|
||||
);
|
||||
|
||||
const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
const createDemoConfig = ({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
}) =>
|
||||
createRspackConfig(
|
||||
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild })
|
||||
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
|
||||
);
|
||||
|
||||
const createCastConfig = ({ isProdBuild, latestBuild }) =>
|
||||
@@ -401,9 +406,19 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
const createE2eTestAppConfig = ({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
}) =>
|
||||
createRspackConfig(
|
||||
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
|
||||
bundle.config.e2eTestApp({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
|
||||
+2
-2
@@ -77,7 +77,7 @@
|
||||
"@lit/task": "1.0.3",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/web": "2.4.1",
|
||||
"@material/web": "2.5.0",
|
||||
"@mdi/js": "7.4.47",
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
@@ -196,7 +196,7 @@
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.0.8",
|
||||
"lint-staged": "17.1.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
|
||||
@@ -58,6 +58,17 @@
|
||||
"depNameTemplate": "rhysd/actionlint",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"description": "Keep Playwright CI container image up to date",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
|
||||
"matchStrings": [
|
||||
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
|
||||
],
|
||||
"depNameTemplate": "mcr.microsoft.com/playwright",
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)-(?<compatibility>noble)$"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
@@ -91,6 +102,11 @@
|
||||
"description": "Group formatjs monorepo package",
|
||||
"groupName": "formatjs",
|
||||
"matchPackageNames": ["@formatjs/**"]
|
||||
},
|
||||
{
|
||||
"description": "Group Playwright package and CI container updates",
|
||||
"groupName": "Playwright",
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+8
-1
@@ -91,7 +91,14 @@ export const STATES_OFF = ["closed", "locked", "off"];
|
||||
export const BINARY_STATE_ON = "on";
|
||||
export const BINARY_STATE_OFF = "off";
|
||||
|
||||
/** Domains where we allow toggle in Lovelace. */
|
||||
/** Domains where we allow toggle in Lovelace.
|
||||
* This is not strictly a list of what is possible to toggle, but the list of
|
||||
* domains where toggle is considered the primary default behavior.
|
||||
* Entities card uses this to determine which entities are controlled by the
|
||||
* header toggle.
|
||||
* Some cards use this to decide the default tap action.
|
||||
* Use canToggleDomain/canToggleState for determining if toggling is possible.
|
||||
*/
|
||||
export const DOMAINS_TOGGLE = new Set([
|
||||
"fan",
|
||||
"input_boolean",
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { getToggleAction } from "./get_toggle_action";
|
||||
|
||||
export const canToggleDomain = (hass: HomeAssistant, domain: string) => {
|
||||
const services = hass.services[domain];
|
||||
if (!services) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (domain === "lock") {
|
||||
return "lock" in services;
|
||||
}
|
||||
if (domain === "cover") {
|
||||
return "open_cover" in services;
|
||||
}
|
||||
return "turn_on" in services;
|
||||
const actionOn = getToggleAction(domain, true);
|
||||
const actionOff = getToggleAction(domain, false);
|
||||
return actionOn in services && actionOff in services;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import { canToggleDomain } from "./can_toggle_domain";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
import { supportsFeature } from "./supports-feature";
|
||||
import { SPECIAL_TOGGLE_ACTIONS } from "./get_toggle_action";
|
||||
|
||||
export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
@@ -25,8 +26,13 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (domain === "climate") {
|
||||
return supportsFeature(stateObj, 4096);
|
||||
if (
|
||||
domain in SPECIAL_TOGGLE_ACTIONS &&
|
||||
SPECIAL_TOGGLE_ACTIONS[domain].feature
|
||||
) {
|
||||
return SPECIAL_TOGGLE_ACTIONS[domain].feature.every((f) =>
|
||||
supportsFeature(stateObj, f)
|
||||
);
|
||||
}
|
||||
|
||||
return canToggleDomain(hass, domain);
|
||||
|
||||
@@ -2,19 +2,19 @@ import { AITaskEntityFeature } from "../../data/ai_task";
|
||||
import { AlarmControlPanelEntityFeature } from "../../data/alarm_control_panel";
|
||||
import { AssistSatelliteEntityFeature } from "../../data/assist_satellite";
|
||||
import { CalendarEntityFeature } from "../../data/calendar";
|
||||
import { CameraEntityFeature } from "../../data/camera";
|
||||
import { ClimateEntityFeature } from "../../data/climate";
|
||||
import { CameraEntityFeature } from "../../data/feature/camera_entity_feature";
|
||||
import { ClimateEntityFeature } from "../../data/feature/climate_entity_feature";
|
||||
import { ConversationEntityFeature } from "../../data/conversation";
|
||||
import { CoverEntityFeature } from "../../data/cover";
|
||||
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
|
||||
import { FanEntityFeature } from "../../data/fan";
|
||||
import { HumidifierEntityFeature } from "../../data/humidifier";
|
||||
import { LawnMowerEntityFeature } from "../../data/lawn_mower";
|
||||
import { LightEntityFeature } from "../../data/light";
|
||||
import { LockEntityFeature } from "../../data/lock";
|
||||
import { MediaPlayerEntityFeature } from "../../data/media-player";
|
||||
import { MediaPlayerEntityFeature } from "../../data/feature/media-player_entity_feature";
|
||||
import { NotifyEntityFeature } from "../../data/notify";
|
||||
import { RemoteEntityFeature } from "../../data/remote";
|
||||
import { SirenEntityFeature } from "../../data/siren";
|
||||
import { SirenEntityFeature } from "../../data/feature/siren_entity_feature";
|
||||
import { TodoListEntityFeature } from "../../data/todo";
|
||||
import { UpdateEntityFeature } from "../../data/update";
|
||||
import { VacuumEntityFeature } from "../../data/vacuum";
|
||||
|
||||
@@ -89,7 +89,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
|
||||
device_class: [
|
||||
"battery",
|
||||
"battery_charging",
|
||||
"co",
|
||||
"carbon_monoxide",
|
||||
"cold",
|
||||
"connectivity",
|
||||
"door",
|
||||
@@ -227,7 +227,12 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
|
||||
"voltage",
|
||||
"volume_flow_rate",
|
||||
],
|
||||
state_class: ["measurement", "total", "total_increasing"],
|
||||
state_class: [
|
||||
"measurement",
|
||||
"measurement_angle",
|
||||
"total",
|
||||
"total_increasing",
|
||||
],
|
||||
},
|
||||
switch: {
|
||||
device_class: ["outlet", "switch"],
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CameraEntityFeature } from "../../data/feature/camera_entity_feature";
|
||||
import { ClimateEntityFeature } from "../../data/feature/climate_entity_feature";
|
||||
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
|
||||
import { MediaPlayerEntityFeature } from "../../data/feature/media-player_entity_feature";
|
||||
import { SirenEntityFeature } from "../../data/feature/siren_entity_feature";
|
||||
|
||||
// These are domains which have nonstandard 'toggle' behavior.
|
||||
// Otherwise, any domain with a turn_on and turn_off service may be toggled.
|
||||
// If features are provided, all features must be supported.
|
||||
interface SpecialToggleAction {
|
||||
on: string;
|
||||
off?: string;
|
||||
feature?: number[];
|
||||
}
|
||||
|
||||
export const SPECIAL_TOGGLE_ACTIONS: Record<string, SpecialToggleAction> = {
|
||||
button: {
|
||||
on: "press",
|
||||
},
|
||||
camera: {
|
||||
on: "turn_on",
|
||||
off: "turn_off",
|
||||
feature: [CameraEntityFeature.ON_OFF],
|
||||
},
|
||||
climate: {
|
||||
on: "turn_on",
|
||||
off: "turn_off",
|
||||
feature: [ClimateEntityFeature.TURN_ON, ClimateEntityFeature.TURN_OFF],
|
||||
},
|
||||
cover: {
|
||||
on: "open_cover",
|
||||
off: "close_cover",
|
||||
feature: [CoverEntityFeature.OPEN, CoverEntityFeature.CLOSE],
|
||||
},
|
||||
input_button: {
|
||||
on: "press",
|
||||
},
|
||||
lock: {
|
||||
on: "unlock",
|
||||
off: "lock",
|
||||
},
|
||||
media_player: {
|
||||
on: "turn_on",
|
||||
off: "turn_off",
|
||||
feature: [
|
||||
MediaPlayerEntityFeature.TURN_ON,
|
||||
MediaPlayerEntityFeature.TURN_OFF,
|
||||
],
|
||||
},
|
||||
scene: {
|
||||
on: "turn_on",
|
||||
},
|
||||
siren: {
|
||||
on: "turn_on",
|
||||
off: "turn_off",
|
||||
feature: [SirenEntityFeature.TURN_ON, SirenEntityFeature.TURN_OFF],
|
||||
},
|
||||
valve: {
|
||||
on: "open_valve",
|
||||
off: "close_valve",
|
||||
},
|
||||
};
|
||||
|
||||
// This function assumes that the passed domain can toggle, it may otherwise
|
||||
// return a service that does not exist.
|
||||
export const getToggleAction = (domain: string, onOff: boolean): string => {
|
||||
return (
|
||||
SPECIAL_TOGGLE_ACTIONS[domain]?.[onOff ? "on" : "off"] ||
|
||||
SPECIAL_TOGGLE_ACTIONS[domain]?.["on"] ||
|
||||
(onOff ? "turn_on" : "turn_off")
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
import { getToggleAction } from "./get_toggle_action";
|
||||
|
||||
export const computeGroupEntitiesState = (states: HassEntity[]): string => {
|
||||
if (!states.length) {
|
||||
@@ -57,14 +58,11 @@ export const toggleGroupEntities = (
|
||||
|
||||
const isOn = state === "on" || state === "open";
|
||||
|
||||
let service = isOn ? "turn_off" : "turn_on";
|
||||
let service = getToggleAction(domain, !isOn);
|
||||
if (domain === "cover") {
|
||||
if (state === "opening" || state === "closing") {
|
||||
// If the cover is opening or closing, we toggle it to stop it
|
||||
service = "stop_cover";
|
||||
} else {
|
||||
// For covers, we use the open/close service
|
||||
service = isOn ? "close_cover" : "open_cover";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
// A range smaller than this fraction of the axis magnitude is floating-point
|
||||
// noise (e.g. from summed statistics), not real precision.
|
||||
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
|
||||
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// observed data range. We mirror how ECharts sizes its ticks: it splits the
|
||||
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
|
||||
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
|
||||
// interval needs. This matches the precision ECharts actually renders, so
|
||||
// labels are neither truncated to identical values nor padded with extra zeros.
|
||||
export function computeYAxisFractionDigits(min: number, max: number): number {
|
||||
const range = max - min;
|
||||
export function computeYAxisFractionDigits(
|
||||
min: number,
|
||||
max: number,
|
||||
// Bar axes render from 0, so union the extent with 0 to match.
|
||||
includeZero = false
|
||||
): number {
|
||||
const lo = includeZero ? Math.min(min, 0) : min;
|
||||
const hi = includeZero ? Math.max(max, 0) : max;
|
||||
const range = hi - lo;
|
||||
if (!Number.isFinite(range) || range <= 0) return 1;
|
||||
// A near-zero range is fp noise; deriving digits from it would pad the labels
|
||||
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
|
||||
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
|
||||
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
|
||||
const rawInterval = range / 5;
|
||||
const exponent = Math.floor(Math.log10(rawInterval));
|
||||
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
|
||||
@@ -38,9 +53,7 @@ const resolveYAxisBound = (
|
||||
export function createYAxisPrecisionBounds(options: {
|
||||
min?: YAxisBound;
|
||||
max?: YAxisBound;
|
||||
// Axes without `scale: true` (e.g. bar charts) stay anchored at 0, so the
|
||||
// rendered ticks span from 0 even when the data does not. Union the extent
|
||||
// with 0 to match the labels ECharts actually draws.
|
||||
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
|
||||
includeZero?: boolean;
|
||||
onFractionDigits: (digits: number) => void;
|
||||
}): {
|
||||
@@ -52,13 +65,11 @@ export function createYAxisPrecisionBounds(options: {
|
||||
min: (values) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
let extentMin = resolvedMin ?? values.min;
|
||||
let extentMax = resolvedMax ?? values.max;
|
||||
if (includeZero) {
|
||||
extentMin = Math.min(extentMin, 0);
|
||||
extentMax = Math.max(extentMax, 0);
|
||||
}
|
||||
onFractionDigits(computeYAxisFractionDigits(extentMin, extentMax));
|
||||
const extentMin = resolvedMin ?? values.min;
|
||||
const extentMax = resolvedMax ?? values.max;
|
||||
onFractionDigits(
|
||||
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
|
||||
);
|
||||
return resolvedMin;
|
||||
},
|
||||
max: (values) => resolveYAxisBound(max, values),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AssistChip } from "@material/web/chips/internal/assist-chip";
|
||||
import { styles } from "@material/web/chips/internal/assist-styles";
|
||||
import { styles } from "@material/web/chips/internal/assist-styles.cssresult.js";
|
||||
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
|
||||
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
|
||||
import { FilterChip } from "@material/web/chips/internal/filter-chip";
|
||||
import { styles } from "@material/web/chips/internal/filter-styles";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
|
||||
import { styles } from "@material/web/chips/internal/filter-styles.cssresult.js";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { InputChip } from "@material/web/chips/internal/input-chip";
|
||||
import { styles } from "@material/web/chips/internal/input-styles";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
|
||||
import { styles } from "@material/web/chips/internal/input-styles.cssresult.js";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ const HIDDEN_ATTRIBUTES = [
|
||||
"battery_level",
|
||||
"code_arm_required",
|
||||
"code_format",
|
||||
"color_modes",
|
||||
"device_class",
|
||||
"editable",
|
||||
"effect_list",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { forwardHaptic } from "../../data/haptics";
|
||||
import "../ha-formfield";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-switch";
|
||||
import { getToggleAction } from "../../common/entity/get_toggle_action";
|
||||
|
||||
const isOn = (stateObj?: HassEntity) =>
|
||||
stateObj !== undefined &&
|
||||
@@ -124,25 +125,10 @@ export class HaEntityToggle extends LitElement {
|
||||
}
|
||||
forwardHaptic(this, "light");
|
||||
const stateDomain = computeStateDomain(this.stateObj);
|
||||
let serviceDomain;
|
||||
let service;
|
||||
|
||||
if (stateDomain === "lock") {
|
||||
serviceDomain = "lock";
|
||||
service = turnOn ? "unlock" : "lock";
|
||||
} else if (stateDomain === "cover") {
|
||||
serviceDomain = "cover";
|
||||
service = turnOn ? "open_cover" : "close_cover";
|
||||
} else if (stateDomain === "valve") {
|
||||
serviceDomain = "valve";
|
||||
service = turnOn ? "open_valve" : "close_valve";
|
||||
} else if (stateDomain === "group") {
|
||||
serviceDomain = "homeassistant";
|
||||
service = turnOn ? "turn_on" : "turn_off";
|
||||
} else {
|
||||
serviceDomain = stateDomain;
|
||||
service = turnOn ? "turn_on" : "turn_off";
|
||||
}
|
||||
const serviceDomain =
|
||||
stateDomain === "group" ? "homeassistant" : stateDomain;
|
||||
const service = getToggleAction(stateDomain, turnOn);
|
||||
|
||||
const currentState = this.stateObj;
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { computeCssColor } from "../../common/color/compute-color";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { stateActive } from "../../common/entity/state_active";
|
||||
import {
|
||||
stateColorBrightness,
|
||||
stateColorCss,
|
||||
@@ -27,10 +29,15 @@ export class StateBadge extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public overrideImage?: string;
|
||||
|
||||
// Cannot be a boolean attribute because undefined is treated different than
|
||||
// false. When it is undefined, state is still colored for light entities.
|
||||
/**
|
||||
* Cannot be a boolean attribute because undefined is treated different than
|
||||
* false. When it is undefined, state is still colored for light entities.
|
||||
* @deprecated use `color` instead
|
||||
*/
|
||||
@property({ attribute: false }) public stateColor?: boolean;
|
||||
|
||||
// "state", "none" or a color (theme color name or CSS color). Custom colors
|
||||
// apply when the entity is active. Takes precedence over stateColor.
|
||||
@property() public color?: string;
|
||||
|
||||
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
|
||||
@@ -67,11 +74,17 @@ export class StateBadge extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private get _stateColor() {
|
||||
private get _color(): string | undefined {
|
||||
if (this.color) {
|
||||
return this.color;
|
||||
}
|
||||
if (this.stateColor !== undefined) {
|
||||
return this.stateColor ? "state" : "none";
|
||||
}
|
||||
const domain = this.stateObj
|
||||
? computeStateDomain(this.stateObj)
|
||||
: undefined;
|
||||
return this.stateColor ?? domain === "light";
|
||||
return domain === "light" ? "state" : undefined;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -131,6 +144,7 @@ export class StateBadge extends LitElement {
|
||||
|
||||
if (stateObj) {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
const color = this._color;
|
||||
if (this.overrideImage === undefined) {
|
||||
// hide icon if we have entity picture
|
||||
if (
|
||||
@@ -147,13 +161,10 @@ export class StateBadge extends LitElement {
|
||||
}
|
||||
backgroundImage = `url(${imageUrl})`;
|
||||
this.icon = false;
|
||||
} else if (this.color) {
|
||||
// Externally provided overriding color wins over state color
|
||||
iconStyle.color = this.color;
|
||||
} else if (this._stateColor) {
|
||||
const color = stateColorCss(stateObj);
|
||||
if (color) {
|
||||
iconStyle.color = color;
|
||||
} else if (color === "state") {
|
||||
const stateColor = stateColorCss(stateObj);
|
||||
if (stateColor) {
|
||||
iconStyle.color = stateColor;
|
||||
}
|
||||
if (stateObj.attributes.rgb_color) {
|
||||
iconStyle.color = `rgb(${stateObj.attributes.rgb_color.join(",")})`;
|
||||
@@ -180,6 +191,8 @@ export class StateBadge extends LitElement {
|
||||
delete iconStyle.color;
|
||||
}
|
||||
}
|
||||
} else if (color && color !== "none" && stateActive(stateObj)) {
|
||||
iconStyle.color = computeCssColor(color);
|
||||
}
|
||||
} else if (this.overrideImage) {
|
||||
backgroundImage = `url(${this._resolveImageUrl(this.overrideImage)})`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-relative-time";
|
||||
import "../ha-tooltip";
|
||||
@@ -23,10 +24,11 @@ class StateInfo extends LitElement {
|
||||
|
||||
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
|
||||
|
||||
// Inline style because the state-badge color API only colors active entities
|
||||
return html`<state-badge
|
||||
.stateObj=${this.stateObj}
|
||||
.stateColor=${true}
|
||||
.color=${this.color}
|
||||
.stateColor=${!this.color}
|
||||
style=${styleMap({ color: this.color })}
|
||||
></state-badge>
|
||||
<div class="info">
|
||||
<div class="name ${this.inDialog ? "in-dialog" : ""}" .title=${name}>
|
||||
|
||||
@@ -57,6 +57,16 @@ interface AssistMessage {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const initialPromptToSubmit = (
|
||||
prompt: string | undefined,
|
||||
submit: boolean
|
||||
): string | undefined => (submit ? prompt?.trim() || undefined : undefined);
|
||||
|
||||
export const assistPipelineChanged = (
|
||||
previous: AssistPipeline | undefined,
|
||||
current: AssistPipeline | undefined
|
||||
): boolean => previous?.id !== current?.id;
|
||||
|
||||
@customElement("ha-assist-chat")
|
||||
export class HaAssistChat extends LitElement {
|
||||
@property({ attribute: false }) public pipeline?: AssistPipeline;
|
||||
@@ -67,6 +77,12 @@ export class HaAssistChat extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public startListening?: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
public initialPrompt?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public submitInitialPrompt = false;
|
||||
|
||||
@query("#message-input") private _messageInput!: HaInput;
|
||||
|
||||
@query(".message:last-child")
|
||||
@@ -99,6 +115,8 @@ export class HaAssistChat extends LitElement {
|
||||
|
||||
private _conversationId: string | null = null;
|
||||
|
||||
private _initialPromptSubmitted = false;
|
||||
|
||||
private _audioRecorder?: AudioRecorder;
|
||||
|
||||
private _audioBuffer?: Int16Array[];
|
||||
@@ -108,7 +126,11 @@ export class HaAssistChat extends LitElement {
|
||||
private _stt_binary_handler_id?: number | null;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (!this.hasUpdated || changedProperties.has("pipeline")) {
|
||||
if (
|
||||
!this.hasUpdated ||
|
||||
(changedProperties.has("pipeline") &&
|
||||
assistPipelineChanged(changedProperties.get("pipeline"), this.pipeline))
|
||||
) {
|
||||
this._conversation = [
|
||||
{
|
||||
who: "hass",
|
||||
@@ -138,6 +160,20 @@ export class HaAssistChat extends LitElement {
|
||||
if (changedProps.has("_conversation")) {
|
||||
this._scrollMessagesBottom();
|
||||
}
|
||||
if (
|
||||
!this._initialPromptSubmitted &&
|
||||
(changedProps.has("initialPrompt") ||
|
||||
changedProps.has("submitInitialPrompt"))
|
||||
) {
|
||||
const prompt = initialPromptToSubmit(
|
||||
this.initialPrompt,
|
||||
this.submitInitialPrompt
|
||||
);
|
||||
if (prompt) {
|
||||
this._initialPromptSubmitted = true;
|
||||
this._processText(prompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
|
||||
@@ -57,17 +57,40 @@ export const evaluateCondition = (
|
||||
return matchFieldCondition(condition, data);
|
||||
};
|
||||
|
||||
export const isFieldHidden = (
|
||||
export const isFieldVisible = (
|
||||
schema: HaFormSchema,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
const { hidden } = schema as HaFormBaseSchema;
|
||||
if (!hidden) {
|
||||
return false;
|
||||
}
|
||||
if (hidden === true) {
|
||||
const { visible } = schema as HaFormBaseSchema;
|
||||
if (visible === undefined || visible === true) {
|
||||
return true;
|
||||
}
|
||||
const conditions = Array.isArray(hidden) ? hidden : [hidden];
|
||||
if (visible === false) {
|
||||
return false;
|
||||
}
|
||||
const conditions = Array.isArray(visible) ? visible : [visible];
|
||||
return conditions.every((condition) => evaluateCondition(condition, data));
|
||||
};
|
||||
|
||||
// Hiding a field drops its value, which can flip another field's condition, so
|
||||
// resolve the set to a fixpoint.
|
||||
export const getHiddenFields = (
|
||||
schema: readonly HaFormSchema[],
|
||||
data: HaFormDataContainer | undefined
|
||||
): Set<string> => {
|
||||
const hidden = new Set<string>();
|
||||
const evalData: HaFormDataContainer = { ...(data ?? {}) };
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const field of schema) {
|
||||
if (hidden.has(field.name) || isFieldVisible(field, evalData)) {
|
||||
continue;
|
||||
}
|
||||
hidden.add(field.name);
|
||||
delete evalData[field.name];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { isFieldHidden } from "./conditions";
|
||||
import { getHiddenFields } from "./conditions";
|
||||
import "./ha-form";
|
||||
import type { HaForm } from "./ha-form";
|
||||
import type {
|
||||
@@ -68,9 +68,11 @@ export class HaFormGrid extends LitElement implements HaFormElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const hiddenFields = getHiddenFields(this.schema.schema, this.data);
|
||||
|
||||
return html`
|
||||
${this.schema.schema
|
||||
.filter((item) => !isFieldHidden(item, this.data))
|
||||
.filter((item) => !hiddenFields.has(item.name))
|
||||
.map(
|
||||
(item) => html`
|
||||
<ha-form
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-alert";
|
||||
import "../ha-selector/ha-selector";
|
||||
import { isFieldHidden } from "./conditions";
|
||||
import { getHiddenFields } from "./conditions";
|
||||
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
|
||||
|
||||
const LOAD_ELEMENTS = {
|
||||
@@ -99,8 +99,9 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
let isValid = true;
|
||||
let firstInvalidElement: HTMLElement | undefined;
|
||||
|
||||
const hiddenFields = getHiddenFields(this.schema, this.data);
|
||||
const visibleSchema = this.schema.filter(
|
||||
(item) => !isFieldHidden(item, this.data)
|
||||
(item) => !hiddenFields.has(item.name)
|
||||
);
|
||||
|
||||
visibleSchema.forEach((item, index) => {
|
||||
@@ -157,6 +158,8 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const renderHiddenFields = getHiddenFields(this.schema, this.data);
|
||||
|
||||
return html`
|
||||
<div class="root" part="root">
|
||||
${
|
||||
@@ -169,7 +172,7 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
: ""
|
||||
}
|
||||
${this.schema.map((item) => {
|
||||
if (isFieldHidden(item, this.data)) {
|
||||
if (renderHiddenFields.has(item.name)) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ export interface HaFormBaseSchema {
|
||||
default?: HaFormData;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
// Field is hidden while the condition holds. Serializable so it can be
|
||||
// shared with the backend and other renderers.
|
||||
hidden?: boolean | HaFormCondition | HaFormCondition[];
|
||||
// Field is visible while the condition holds (visible by default).
|
||||
// Serializable so it can be shared with the backend and other renderers.
|
||||
visible?: boolean | HaFormCondition | HaFormCondition[];
|
||||
description?: {
|
||||
suffix?: string;
|
||||
// This value will be set initially when form is loaded
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ListItemEl } from "@material/web/list/internal/listitem/list-item";
|
||||
import { styles } from "@material/web/list/internal/listitem/list-item-styles";
|
||||
import { styles } from "@material/web/list/internal/listitem/list-item-styles.cssresult.js";
|
||||
import { css, html, nothing, type TemplateResult } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "./ha-ripple";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { List } from "@material/web/list/internal/list";
|
||||
import { styles } from "@material/web/list/internal/list-styles";
|
||||
import { styles } from "@material/web/list/internal/list-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OutlinedButton } from "@material/web/button/internal/outlined-button";
|
||||
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles";
|
||||
import { styles } from "@material/web/button/internal/outlined-styles";
|
||||
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles.cssresult.js";
|
||||
import { styles } from "@material/web/button/internal/outlined-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OutlinedField } from "@material/web/field/internal/outlined-field";
|
||||
import { styles } from "@material/web/field/internal/outlined-styles";
|
||||
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles";
|
||||
import { styles } from "@material/web/field/internal/outlined-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { literal } from "lit/static-html";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IconButton } from "@material/web/iconbutton/internal/icon-button";
|
||||
import { styles } from "@material/web/iconbutton/internal/outlined-styles";
|
||||
import { styles as sharedStyles } from "@material/web/iconbutton/internal/shared-styles";
|
||||
import { styles } from "@material/web/iconbutton/internal/outlined-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/iconbutton/internal/shared-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AttachableController } from "@material/web/internal/controller/attachable-controller";
|
||||
import { Ripple } from "@material/web/ripple/internal/ripple";
|
||||
import { styles } from "@material/web/ripple/internal/ripple-styles";
|
||||
import { styles } from "@material/web/ripple/internal/ripple-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { initialState } from "@lit/task";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { AsyncValueTask } from "../../common/controllers/async-value-task";
|
||||
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
entitiesContext,
|
||||
} from "../../data/context";
|
||||
import { entityIcon } from "../../data/icons";
|
||||
import type { IconSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-icon-picker";
|
||||
import "../ha-state-icon";
|
||||
|
||||
@customElement("ha-selector-icon")
|
||||
export class HaIconSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: IconSelector;
|
||||
|
||||
@property() public value?: string;
|
||||
@@ -30,10 +34,21 @@ export class HaIconSelector extends LitElement {
|
||||
icon_entity?: string;
|
||||
};
|
||||
|
||||
private get _stateObj(): HassEntity | undefined {
|
||||
const iconEntity = this.context?.icon_entity;
|
||||
return iconEntity ? this.hass.states[iconEntity] : undefined;
|
||||
}
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["context", "icon_entity"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state()
|
||||
@consume({ context: entitiesContext, subscribe: true })
|
||||
private _entities?: ContextType<typeof entitiesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config?: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection?: ContextType<typeof connectionContext>;
|
||||
|
||||
private _placeholderTask = new AsyncValueTask(this, {
|
||||
task: ([
|
||||
@@ -44,19 +59,31 @@ export class HaIconSelector extends LitElement {
|
||||
connection,
|
||||
stateObj,
|
||||
]) => {
|
||||
if (placeholder || attributeIcon || !stateObj) {
|
||||
if (
|
||||
placeholder ||
|
||||
attributeIcon ||
|
||||
!entities ||
|
||||
!config ||
|
||||
!connection ||
|
||||
!stateObj
|
||||
) {
|
||||
return initialState;
|
||||
}
|
||||
return entityIcon(entities, config, connection, stateObj);
|
||||
return entityIcon(
|
||||
entities,
|
||||
config.config,
|
||||
connection.connection,
|
||||
stateObj
|
||||
);
|
||||
},
|
||||
args: () => {
|
||||
const stateObj = this._stateObj;
|
||||
return [
|
||||
this.selector.icon?.placeholder,
|
||||
stateObj?.attributes.icon,
|
||||
this.hass.entities,
|
||||
this.hass.config,
|
||||
this.hass.connection,
|
||||
this._entities,
|
||||
this._config,
|
||||
this._connection,
|
||||
stateObj,
|
||||
] as const;
|
||||
},
|
||||
@@ -72,7 +99,6 @@ export class HaIconSelector extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-icon-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.label}
|
||||
.value=${this.value}
|
||||
.required=${this.required}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from "../../common/translations/localize";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-form/ha-form";
|
||||
import { unitOfMeasurementOptions } from "../../data/number";
|
||||
|
||||
const SELECTOR_DEFAULTS = {
|
||||
number: {
|
||||
@@ -112,6 +113,16 @@ const SELECTOR_SCHEMAS = {
|
||||
name: "step",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "unit_of_measurement",
|
||||
selector: {
|
||||
select: {
|
||||
custom_value: true,
|
||||
sort: true,
|
||||
options: unitOfMeasurementOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const,
|
||||
object: [] as const,
|
||||
color_rgb: [] as const,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SubMenu } from "@material/web/menu/internal/submenu/sub-menu";
|
||||
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles";
|
||||
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
import { memoize } from "@fullcalendar/core/internal";
|
||||
import { setHours, setMinutes } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -411,7 +410,7 @@ export type BackupType = "automatic" | "manual" | "app_update";
|
||||
|
||||
const BACKUP_TYPE_ORDER: BackupType[] = ["automatic", "app_update", "manual"];
|
||||
|
||||
export const getBackupTypes = memoize((isHassio: boolean) =>
|
||||
export const getBackupTypes = memoizeOne((isHassio: boolean) =>
|
||||
isHassio
|
||||
? BACKUP_TYPE_ORDER
|
||||
: BACKUP_TYPE_ORDER.filter((type) => type !== "app_update")
|
||||
|
||||
+1
-4
@@ -13,10 +13,7 @@ export const STREAM_TYPE_WEB_RTC = "web_rtc";
|
||||
|
||||
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
|
||||
|
||||
export enum CameraEntityFeature {
|
||||
ON_OFF = 1,
|
||||
STREAM = 2,
|
||||
}
|
||||
export { CameraEntityFeature } from "./feature/camera_entity_feature";
|
||||
|
||||
interface CameraEntityAttributes extends HassEntityAttributeBase {
|
||||
model_name: string;
|
||||
|
||||
+1
-12
@@ -68,18 +68,7 @@ export type ClimateEntity = HassEntityBase & {
|
||||
};
|
||||
};
|
||||
|
||||
export enum ClimateEntityFeature {
|
||||
TARGET_TEMPERATURE = 1,
|
||||
TARGET_TEMPERATURE_RANGE = 2,
|
||||
TARGET_HUMIDITY = 4,
|
||||
FAN_MODE = 8,
|
||||
PRESET_MODE = 16,
|
||||
SWING_MODE = 32,
|
||||
AUX_HEAT = 64,
|
||||
TURN_OFF = 128,
|
||||
TURN_ON = 256,
|
||||
SWING_HORIZONTAL_MODE = 512,
|
||||
}
|
||||
export { ClimateEntityFeature } from "./feature/climate_entity_feature";
|
||||
|
||||
const hvacModeOrdering = HVAC_MODES.reduce(
|
||||
(order, mode, index) => {
|
||||
|
||||
+2
-10
@@ -6,17 +6,9 @@ import { stateActive } from "../common/entity/state_active";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { HomeAssistantFormatters } from "../types";
|
||||
import { UNAVAILABLE } from "./entity/entity";
|
||||
import { CoverEntityFeature } from "./feature/cover_entity_feature";
|
||||
|
||||
export enum CoverEntityFeature {
|
||||
OPEN = 1,
|
||||
CLOSE = 2,
|
||||
SET_POSITION = 4,
|
||||
STOP = 8,
|
||||
OPEN_TILT = 16,
|
||||
CLOSE_TILT = 32,
|
||||
STOP_TILT = 64,
|
||||
SET_TILT_POSITION = 128,
|
||||
}
|
||||
export { CoverEntityFeature };
|
||||
|
||||
export const DEFAULT_COVER_FAVORITE_POSITIONS = [0, 25, 75, 100];
|
||||
|
||||
|
||||
@@ -127,7 +127,6 @@ export const NON_NUMERIC_ATTRIBUTES = [
|
||||
"away_mode",
|
||||
"changed_by",
|
||||
"code_format",
|
||||
"color_modes",
|
||||
"current_activity",
|
||||
"device_class",
|
||||
"editable",
|
||||
@@ -177,6 +176,7 @@ export const NON_NUMERIC_ATTRIBUTES = [
|
||||
"source_type",
|
||||
"source",
|
||||
"state_class",
|
||||
"supported_color_modes",
|
||||
"supported_features",
|
||||
"swing_mode",
|
||||
"swing_mode",
|
||||
@@ -190,7 +190,6 @@ export const NON_NUMERIC_ATTRIBUTES = [
|
||||
export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
|
||||
"access_token",
|
||||
"available_modes",
|
||||
"color_modes",
|
||||
"editable",
|
||||
"effect_list",
|
||||
"entity_picture",
|
||||
@@ -207,6 +206,7 @@ export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
|
||||
"sound_mode_list",
|
||||
"source_list",
|
||||
"state_class",
|
||||
"supported_color_modes",
|
||||
"swing_modes",
|
||||
"token",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum CameraEntityFeature {
|
||||
ON_OFF = 1,
|
||||
STREAM = 2,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export enum ClimateEntityFeature {
|
||||
TARGET_TEMPERATURE = 1,
|
||||
TARGET_TEMPERATURE_RANGE = 2,
|
||||
TARGET_HUMIDITY = 4,
|
||||
FAN_MODE = 8,
|
||||
PRESET_MODE = 16,
|
||||
SWING_MODE = 32,
|
||||
AUX_HEAT = 64,
|
||||
TURN_OFF = 128,
|
||||
TURN_ON = 256,
|
||||
SWING_HORIZONTAL_MODE = 512,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum CoverEntityFeature {
|
||||
OPEN = 1,
|
||||
CLOSE = 2,
|
||||
SET_POSITION = 4,
|
||||
STOP = 8,
|
||||
OPEN_TILT = 16,
|
||||
CLOSE_TILT = 32,
|
||||
STOP_TILT = 64,
|
||||
SET_TILT_POSITION = 128,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export enum MediaPlayerEntityFeature {
|
||||
PAUSE = 1,
|
||||
SEEK = 2,
|
||||
VOLUME_SET = 4,
|
||||
VOLUME_MUTE = 8,
|
||||
PREVIOUS_TRACK = 16,
|
||||
NEXT_TRACK = 32,
|
||||
|
||||
TURN_ON = 128,
|
||||
TURN_OFF = 256,
|
||||
PLAY_MEDIA = 512,
|
||||
VOLUME_STEP = 1024,
|
||||
SELECT_SOURCE = 2048,
|
||||
STOP = 4096,
|
||||
CLEAR_PLAYLIST = 8192,
|
||||
PLAY = 16384,
|
||||
SHUFFLE_SET = 32768,
|
||||
SELECT_SOUND_MODE = 65536,
|
||||
BROWSE_MEDIA = 131072,
|
||||
REPEAT_SET = 262144,
|
||||
GROUPING = 524288,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum SirenEntityFeature {
|
||||
TURN_ON = 1,
|
||||
TURN_OFF = 2,
|
||||
TONES = 4,
|
||||
VOLUME_SET = 8,
|
||||
DURATION = 16,
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { ShortcutItem } from "./home_shortcuts";
|
||||
|
||||
export interface SurveyInteraction {
|
||||
date: string;
|
||||
action: "opened" | "dismissed";
|
||||
}
|
||||
|
||||
export interface CoreFrontendUserData {
|
||||
showEntityIdPicker?: boolean;
|
||||
default_panel?: string;
|
||||
@@ -16,6 +21,9 @@ export interface CoreFrontendSystemData {
|
||||
default_panel?: string;
|
||||
onboarded_version?: string;
|
||||
onboarded_date?: string;
|
||||
surveys?: {
|
||||
onboarding?: SurveyInteraction;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HomeFrontendSystemData {
|
||||
|
||||
@@ -45,6 +45,69 @@ export interface MatterNodeDiagnostics {
|
||||
|
||||
export type MatterPingResult = Record<string, boolean>;
|
||||
|
||||
export type MatterTopologyNodeKind =
|
||||
"matter" | "border_router" | "thread_unknown" | "wifi_ap";
|
||||
|
||||
export type MatterTopologyStrength = "strong" | "medium" | "weak" | "none";
|
||||
|
||||
export interface MatterTopologyDirectionInfo {
|
||||
strength: MatterTopologyStrength;
|
||||
lqi?: number | null;
|
||||
rssi?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopologyNode {
|
||||
id: string;
|
||||
kind: MatterTopologyNodeKind;
|
||||
network_type: string;
|
||||
node_id?: number | null;
|
||||
ha_device_id?: string | null;
|
||||
role?: string | null;
|
||||
available?: boolean | null;
|
||||
is_bridge?: boolean | null;
|
||||
ext_address?: string | null;
|
||||
rloc16?: number | null;
|
||||
ext_pan_id?: string | null;
|
||||
network_name?: string | null;
|
||||
vendor_name?: string | null;
|
||||
model_name?: string | null;
|
||||
last_seen?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopologyConnection {
|
||||
source: string;
|
||||
target: string;
|
||||
network: string;
|
||||
strength: MatterTopologyStrength;
|
||||
source_to_target?: MatterTopologyDirectionInfo | null;
|
||||
target_to_source?: MatterTopologyDirectionInfo | null;
|
||||
via_route_table?: boolean | null;
|
||||
path_cost?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopology {
|
||||
collected_at: number;
|
||||
nodes: MatterNetworkTopologyNode[];
|
||||
connections: MatterNetworkTopologyConnection[];
|
||||
}
|
||||
|
||||
export const fetchMatterNetworkTopology = (
|
||||
hass: HomeAssistant,
|
||||
refresh = false
|
||||
): Promise<MatterNetworkTopology> =>
|
||||
hass.callWS({
|
||||
type: "matter/network_topology",
|
||||
refresh,
|
||||
});
|
||||
|
||||
export const subscribeMatterNetworkTopology = (
|
||||
hass: HomeAssistant,
|
||||
callback: (topology: MatterNetworkTopology) => void
|
||||
): Promise<UnsubscribeFunc> =>
|
||||
hass.connection.subscribeMessage<MatterNetworkTopology>(callback, {
|
||||
type: "matter/subscribe_network_topology",
|
||||
});
|
||||
|
||||
export interface MatterCommissioningParameters {
|
||||
setup_pin_code: number;
|
||||
setup_manual_code: string;
|
||||
|
||||
@@ -40,6 +40,9 @@ import type { MediaPlayerItemId } from "../components/media-player/ha-media-play
|
||||
import type { HomeAssistant, TranslationDict } from "../types";
|
||||
import { UNAVAILABLE } from "./entity/entity";
|
||||
import { isTTSMediaSource } from "./tts";
|
||||
import { MediaPlayerEntityFeature } from "./feature/media-player_entity_feature";
|
||||
|
||||
export { MediaPlayerEntityFeature };
|
||||
|
||||
interface MediaPlayerEntityAttributes extends HassEntityAttributeBase {
|
||||
media_content_id?: string;
|
||||
@@ -82,29 +85,6 @@ export interface MediaPlayerEntity extends HassEntityBase {
|
||||
| "buffering";
|
||||
}
|
||||
|
||||
export enum MediaPlayerEntityFeature {
|
||||
PAUSE = 1,
|
||||
SEEK = 2,
|
||||
VOLUME_SET = 4,
|
||||
VOLUME_MUTE = 8,
|
||||
PREVIOUS_TRACK = 16,
|
||||
NEXT_TRACK = 32,
|
||||
|
||||
TURN_ON = 128,
|
||||
TURN_OFF = 256,
|
||||
PLAY_MEDIA = 512,
|
||||
VOLUME_STEP = 1024,
|
||||
SELECT_SOURCE = 2048,
|
||||
STOP = 4096,
|
||||
CLEAR_PLAYLIST = 8192,
|
||||
PLAY = 16384,
|
||||
SHUFFLE_SET = 32768,
|
||||
SELECT_SOUND_MODE = 65536,
|
||||
BROWSE_MEDIA = 131072,
|
||||
REPEAT_SET = 262144,
|
||||
GROUPING = 524288,
|
||||
}
|
||||
|
||||
export type MediaPlayerBrowseAction = "pick" | "play";
|
||||
|
||||
export const BROWSER_PLAYER = "browser";
|
||||
|
||||
+1
-7
@@ -1,7 +1 @@
|
||||
export enum SirenEntityFeature {
|
||||
TURN_ON = 1,
|
||||
TURN_OFF = 2,
|
||||
TONES = 4,
|
||||
VOLUME_SET = 8,
|
||||
DURATION = 16,
|
||||
}
|
||||
export { SirenEntityFeature } from "./feature/siren_entity_feature";
|
||||
|
||||
@@ -149,6 +149,7 @@ export const weatherAttrIcons = {
|
||||
humidity: mdiWaterPercent,
|
||||
wind_bearing: mdiWeatherWindy,
|
||||
wind_speed: mdiWeatherWindy,
|
||||
wind_gust_speed: mdiWeatherWindy,
|
||||
pressure: mdiGauge,
|
||||
temperature: mdiThermometer,
|
||||
uv_index: mdiSunWireless,
|
||||
@@ -268,6 +269,7 @@ export const getWeatherUnit = (
|
||||
return (
|
||||
stateObj.attributes.temperature_unit || config.unit_system.temperature
|
||||
);
|
||||
case "wind_gust_speed":
|
||||
case "wind_speed":
|
||||
return stateObj.attributes.wind_speed_unit || `${lengthUnit}/h`;
|
||||
case "cloud_coverage":
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../../common/dom/is-navigation-click";
|
||||
import "../../components/ha-alert";
|
||||
import { computeInitialHaFormData } from "../../components/ha-form/compute-initial-ha-form-data";
|
||||
import { getHiddenFields } from "../../components/ha-form/conditions";
|
||||
import "../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
@@ -226,14 +227,17 @@ class StepFlowForm extends LitElement {
|
||||
const checkAllRequiredFields = (
|
||||
schema: readonly HaFormSchema[],
|
||||
data: Record<string, any>
|
||||
) =>
|
||||
schema.every(
|
||||
) => {
|
||||
const hidden = getHiddenFields(schema, data);
|
||||
return schema.every(
|
||||
(field) =>
|
||||
(!field.required || !["", undefined].includes(data[field.name])) &&
|
||||
(field.type !== "expandable" ||
|
||||
(!field.required && data[field.name] === undefined) ||
|
||||
checkAllRequiredFields(field.schema, data[field.name]))
|
||||
hidden.has(field.name) ||
|
||||
((!field.required || !["", undefined].includes(data[field.name])) &&
|
||||
(field.type !== "expandable" ||
|
||||
(!field.required && data[field.name] === undefined) ||
|
||||
checkAllRequiredFields(field.schema, data[field.name])))
|
||||
);
|
||||
};
|
||||
|
||||
const allRequiredInfoFilledIn =
|
||||
stepData === undefined
|
||||
@@ -255,8 +259,14 @@ class StepFlowForm extends LitElement {
|
||||
|
||||
const flowId = this.step.flow_id;
|
||||
|
||||
const hiddenFields = getHiddenFields(this.step.data_schema, stepData);
|
||||
|
||||
const toSendData: Record<string, unknown> = {};
|
||||
Object.keys(stepData).forEach((key) => {
|
||||
if (hiddenFields.has(key)) {
|
||||
// Hidden fields are not part of the submitted config
|
||||
return;
|
||||
}
|
||||
const value = stepData[key];
|
||||
const isEmpty = [undefined, ""].includes(value);
|
||||
const field = this.step.data_schema?.find((f) => f.name === key);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiDevices } from "@mdi/js";
|
||||
import { mdiCommentProcessingOutline, mdiDevices } from "@mdi/js";
|
||||
import { consume } from "@lit/context";
|
||||
import Fuse from "fuse.js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
@@ -61,6 +61,7 @@ import { isIosApp } from "../../util/is_ios";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
import { showConfirmationDialog } from "../generic/show-dialog-box";
|
||||
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
|
||||
import { showVoiceCommandDialog } from "../voice-command-dialog/show-ha-voice-command-dialog";
|
||||
import {
|
||||
effectiveQuickBarMode,
|
||||
type QuickBarParams,
|
||||
@@ -69,6 +70,18 @@ import {
|
||||
|
||||
const SEPARATOR = "________";
|
||||
|
||||
interface AssistComboBoxItem extends PickerComboBoxItem {
|
||||
action: "assist";
|
||||
assistPrompt: string;
|
||||
}
|
||||
|
||||
type QuickBarComboBoxItem =
|
||||
| NavigationComboBoxItem
|
||||
| ActionCommandComboBoxItem
|
||||
| EntityComboBoxItem
|
||||
| DevicePickerItem
|
||||
| AssistComboBoxItem;
|
||||
|
||||
@customElement("ha-quick-bar")
|
||||
export class QuickBar extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -290,13 +303,7 @@ export class QuickBar extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderRow = (
|
||||
item:
|
||||
| NavigationComboBoxItem
|
||||
| ActionCommandComboBoxItem
|
||||
| EntityComboBoxItem
|
||||
| DevicePickerItem
|
||||
) => {
|
||||
private _renderRow = (item: QuickBarComboBoxItem) => {
|
||||
if (!item) {
|
||||
return nothing;
|
||||
}
|
||||
@@ -412,8 +419,8 @@ export class QuickBar extends LitElement {
|
||||
}: {
|
||||
firstIndex: number;
|
||||
lastIndex: number;
|
||||
firstItem: PickerComboBoxItem | string;
|
||||
secondItem: PickerComboBoxItem | string;
|
||||
firstItem: QuickBarComboBoxItem | string;
|
||||
secondItem: QuickBarComboBoxItem | string;
|
||||
itemsCount: number;
|
||||
}) => {
|
||||
if (
|
||||
@@ -461,7 +468,23 @@ export class QuickBar extends LitElement {
|
||||
filter?: string,
|
||||
section?: QuickBarSection
|
||||
) => {
|
||||
const items: (string | PickerComboBoxItem)[] = [];
|
||||
const items: (string | QuickBarComboBoxItem)[] = [];
|
||||
const prompt = filter?.trim();
|
||||
const assistItem =
|
||||
prompt &&
|
||||
(!section || section === "command") &&
|
||||
isComponentLoaded(this.hass.config, "conversation") &&
|
||||
!this.hass.auth.external?.config.hasAssist
|
||||
? ({
|
||||
id: "ask-assist",
|
||||
action: "assist",
|
||||
primary: this.hass.localize("ui.dialogs.quick-bar.ask_assist", {
|
||||
query: prompt,
|
||||
}),
|
||||
icon_path: mdiCommentProcessingOutline,
|
||||
assistPrompt: prompt,
|
||||
} satisfies AssistComboBoxItem)
|
||||
: undefined;
|
||||
|
||||
if (!section || section === "navigate") {
|
||||
let navigateItems = this._generateNavigationCommandsMemoized(
|
||||
@@ -486,10 +509,12 @@ export class QuickBar extends LitElement {
|
||||
items.push(...navigateItems);
|
||||
}
|
||||
|
||||
if (this.hass.user?.is_admin && (!section || section === "command")) {
|
||||
let commandItems = this._generateActionCommandsMemoized(this.hass).sort(
|
||||
this._sortBySortingLabel
|
||||
);
|
||||
if (!section || section === "command") {
|
||||
let commandItems = this.hass.user?.is_admin
|
||||
? this._generateActionCommandsMemoized(this.hass).sort(
|
||||
this._sortBySortingLabel
|
||||
)
|
||||
: [];
|
||||
|
||||
if (filter) {
|
||||
commandItems = this._filterGroup(
|
||||
@@ -499,12 +524,15 @@ export class QuickBar extends LitElement {
|
||||
) as ActionCommandComboBoxItem[];
|
||||
}
|
||||
|
||||
if (!section && commandItems.length) {
|
||||
if (!section && (commandItems.length || assistItem)) {
|
||||
// show group title
|
||||
items.push(this.hass.localize("ui.dialogs.quick-bar.commands_title"));
|
||||
}
|
||||
|
||||
items.push(...commandItems);
|
||||
if (assistItem) {
|
||||
items.push(assistItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (!section || section === "entity") {
|
||||
@@ -723,10 +751,21 @@ export class QuickBar extends LitElement {
|
||||
const { index, newTab } = ev.detail;
|
||||
const item = this._comboBox.virtualizerElement.items[
|
||||
index
|
||||
] as PickerComboBoxItem;
|
||||
] as QuickBarComboBoxItem;
|
||||
|
||||
this._itemSelected = true;
|
||||
|
||||
if (item && "assistPrompt" in item) {
|
||||
this.closeDialog();
|
||||
showVoiceCommandDialog(this, this.hass, {
|
||||
pipeline_id: "last_used",
|
||||
start_listening: false,
|
||||
prompt: item.assistPrompt,
|
||||
submit: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// entity selected
|
||||
if (item && "stateObj" in item) {
|
||||
this.closeDialog();
|
||||
|
||||
@@ -58,9 +58,11 @@ export class HaVoiceCommandDialog extends LitElement {
|
||||
|
||||
private _startListening = false;
|
||||
|
||||
public async showDialog(
|
||||
params: Required<VoiceCommandDialogParams>
|
||||
): Promise<void> {
|
||||
private _prompt?: string;
|
||||
|
||||
private _submitPrompt = false;
|
||||
|
||||
public async showDialog(params: VoiceCommandDialogParams): Promise<void> {
|
||||
await this._loadPipelines();
|
||||
const pipelinesIds = this._pipelines?.map((pipeline) => pipeline.id) || [];
|
||||
if (
|
||||
@@ -77,7 +79,9 @@ export class HaVoiceCommandDialog extends LitElement {
|
||||
this._pipelineId = this._preferredPipeline;
|
||||
}
|
||||
|
||||
this._startListening = params.start_listening;
|
||||
this._startListening = params.start_listening ?? false;
|
||||
this._prompt = params.prompt;
|
||||
this._submitPrompt = params.submit ?? false;
|
||||
this._dialogOpen = true;
|
||||
this._open = true;
|
||||
}
|
||||
@@ -189,6 +193,8 @@ export class HaVoiceCommandDialog extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.pipeline=${this._pipeline}
|
||||
.startListening=${this._startListening}
|
||||
.initialPrompt=${this._prompt}
|
||||
.submitInitialPrompt=${this._submitPrompt}
|
||||
>
|
||||
</ha-assist-chat>
|
||||
`
|
||||
|
||||
@@ -6,6 +6,8 @@ const loadVoiceCommandDialog = () => import("./ha-voice-command-dialog");
|
||||
export interface VoiceCommandDialogParams {
|
||||
pipeline_id: "last_used" | "preferred" | string;
|
||||
start_listening?: boolean;
|
||||
prompt?: string;
|
||||
submit?: boolean;
|
||||
}
|
||||
|
||||
export const showVoiceCommandDialog = (
|
||||
@@ -31,6 +33,8 @@ export const showVoiceCommandDialog = (
|
||||
pipeline_id: dialogParams.pipeline_id,
|
||||
// Don't start listening by default for web
|
||||
start_listening: dialogParams.start_listening ?? false,
|
||||
prompt: dialogParams.prompt,
|
||||
submit: dialogParams.submit ?? false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -124,7 +124,7 @@ interface EMOutgoingMessageConnectionStatus extends EMMessage {
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageFrontendLoaded extends EMMessage {
|
||||
type: "frontend/loaded"; // Fired once the launch screen is removed (connected and essential data loaded)
|
||||
type: "frontend/loaded"; // Fired once the launch screen is removed; with hasSplashscreen this is after the first panel has rendered
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageAppConfiguration extends EMMessage {
|
||||
@@ -371,6 +371,7 @@ export interface ExternalConfig {
|
||||
appVersion?: string;
|
||||
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
|
||||
hasAssistSettings?: boolean; // Shows the "This device" section in voice assistant settings
|
||||
hasSplashscreen?: boolean; // App covers the frontend with its own loading screen until frontend/loaded, so the launch screen is removed without animation
|
||||
}
|
||||
|
||||
export interface ExternalEntityAddToAction {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
removeLaunchScreen,
|
||||
renderLaunchScreenInfoBox,
|
||||
} from "../util/launch-screen";
|
||||
import { checkOnboardingSurveyToast } from "../util/onboarding-survey";
|
||||
import {
|
||||
registerServiceWorker,
|
||||
supportsServiceWorker,
|
||||
@@ -59,6 +60,8 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
|
||||
private _httpPendingDialogOpen = false;
|
||||
|
||||
private _onboardingSurveyChecked = false;
|
||||
|
||||
private _panelUrl: string;
|
||||
|
||||
@storage({ key: "ha-version", state: false, subscribe: false })
|
||||
@@ -108,6 +111,17 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
) {
|
||||
this.checkHttpPendingConfig();
|
||||
}
|
||||
if (
|
||||
changedProps.has("hass") &&
|
||||
!this._onboardingSurveyChecked &&
|
||||
this.hass?.user &&
|
||||
this.hass.systemData
|
||||
) {
|
||||
this._onboardingSurveyChecked = true;
|
||||
if (!__DEMO__) {
|
||||
checkOnboardingSurveyToast(this, this.hass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
@@ -119,7 +133,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
) {
|
||||
this.render = this.renderHass;
|
||||
this.update = super.update;
|
||||
removeLaunchScreen();
|
||||
// Apps with a native splash screen keep covering the frontend until
|
||||
// frontend/loaded, so the launch screen stays up (invisibly) until the
|
||||
// first panel has rendered and partial-panel-resolver removes it.
|
||||
if (!this.hass.auth.external?.config.hasSplashscreen) {
|
||||
removeLaunchScreen();
|
||||
}
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
}
|
||||
super.update(changedProps);
|
||||
|
||||
@@ -220,7 +220,8 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
) {
|
||||
await this.rebuild();
|
||||
await this.pageRendered;
|
||||
removeLaunchScreen();
|
||||
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen);
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ShowToastParams {
|
||||
message:
|
||||
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
|
||||
action?: ToastActionParams;
|
||||
dismiss?: () => void;
|
||||
duration?: number;
|
||||
dismissable?: boolean;
|
||||
bottomOffset?: number;
|
||||
@@ -71,7 +72,10 @@ class NotificationManager extends LitElement {
|
||||
this._toast?.show();
|
||||
}
|
||||
|
||||
private _toastClosed(_ev: HASSDomEvent<ToastClosedEventDetail>) {
|
||||
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
|
||||
if (ev.detail.reason === "dismiss") {
|
||||
this._parameters?.dismiss?.();
|
||||
}
|
||||
this._parameters = undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ import { showMoreInfoDialog } from "../../../../../dialogs/more-info/show-ha-mor
|
||||
import { MobileAwareMixin } from "../../../../../mixins/mobile-aware-mixin";
|
||||
import { mdiHomeAssistant } from "../../../../../resources/home-assistant-logo-svg";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { Route } from "../../../../../types";
|
||||
import type { HomeAssistantRegistries, Route } from "../../../../../types";
|
||||
import { bytesToString } from "../../../../../util/bytes-to-string";
|
||||
import { getAppDisplayName } from "../../common/app";
|
||||
import "../../components/supervisor-apps-state";
|
||||
@@ -172,10 +172,20 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._computeUpdateEntityId();
|
||||
this._startPolling();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Registries load asynchronously and change over time; resolve the update
|
||||
// entity reactively instead of only once on connect.
|
||||
this._updateEntityId = this._findUpdateEntityId(
|
||||
this.registries.devices,
|
||||
this.registries.entities,
|
||||
(this._currentAddon as HassioAddonDetails).slug
|
||||
);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopPolling();
|
||||
@@ -1500,26 +1510,24 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
"system_managed" in addon && addon.system_managed
|
||||
);
|
||||
|
||||
private _computeUpdateEntityId() {
|
||||
const addon = this._currentAddon as HassioAddonDetails;
|
||||
const device = Object.values(this.registries.devices).find((d) =>
|
||||
d.identifiers.some(
|
||||
([domain, id]) => domain === "hassio" && id === addon.slug
|
||||
)
|
||||
);
|
||||
if (!device) {
|
||||
return;
|
||||
private _findUpdateEntityId = memoizeOne(
|
||||
(
|
||||
devices: HomeAssistantRegistries["devices"],
|
||||
entities: HomeAssistantRegistries["entities"],
|
||||
slug: string
|
||||
): string | undefined => {
|
||||
const device = Object.values(devices).find((d) =>
|
||||
d.identifiers.some(([domain, id]) => domain === "hassio" && id === slug)
|
||||
);
|
||||
if (!device) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.values(entities).find(
|
||||
(e) =>
|
||||
e.device_id === device.id && computeDomain(e.entity_id) === "update"
|
||||
)?.entity_id;
|
||||
}
|
||||
const updateEntity = Object.values(this.registries.entities).find(
|
||||
(e) =>
|
||||
e.device_id === device.id && computeDomain(e.entity_id) === "update"
|
||||
);
|
||||
if (!updateEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateEntityId = updateEntity.entity_id;
|
||||
}
|
||||
);
|
||||
|
||||
private _openUpdate() {
|
||||
showMoreInfoDialog(this, { entityId: this._updateEntityId! });
|
||||
|
||||
@@ -30,7 +30,6 @@ const SCHEMA = [
|
||||
"code_arm_required",
|
||||
"code_format",
|
||||
"color_mode",
|
||||
"color_modes",
|
||||
"current_activity",
|
||||
"device_class",
|
||||
"editable",
|
||||
|
||||
@@ -80,7 +80,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
"available_modes",
|
||||
"code_arm_required",
|
||||
"code_format",
|
||||
"color_modes",
|
||||
"device_class",
|
||||
"editable",
|
||||
"effect_list",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mdiPlus,
|
||||
mdiShape,
|
||||
mdiTune,
|
||||
mdiVectorPolyline,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -144,6 +145,10 @@ export class MatterConfigDashboard extends LitElement {
|
||||
<ha-card class="nav-card">
|
||||
<div class="card-header">
|
||||
${this.hass.localize("ui.panel.config.matter.panel.my_network_title")}
|
||||
<ha-button appearance="filled" href="/config/matter/visualization">
|
||||
<ha-svg-icon slot="start" .path=${mdiVectorPolyline}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.matter.panel.show_map")}
|
||||
</ha-button>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
@@ -252,6 +257,9 @@ export class MatterConfigDashboard extends LitElement {
|
||||
}
|
||||
|
||||
.nav-card .card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ class MatterConfigRouter extends HassRouterPage {
|
||||
tag: "matter-options-page",
|
||||
load: () => import("./matter-options-page"),
|
||||
},
|
||||
visualization: {
|
||||
tag: "matter-network-visualization",
|
||||
load: () => import("./matter-network-visualization"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
|
||||
import type {
|
||||
NetworkData,
|
||||
NetworkLink,
|
||||
NetworkNode,
|
||||
} from "../../../../../components/chart/ha-network-graph";
|
||||
import type {
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyNode,
|
||||
MatterTopologyStrength,
|
||||
} from "../../../../../data/matter";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
const CATEGORY_HOME_ASSISTANT = 0;
|
||||
const CATEGORY_BORDER_ROUTER = 1;
|
||||
const CATEGORY_ROUTER = 2;
|
||||
const CATEGORY_END_DEVICE = 3;
|
||||
const CATEGORY_WIFI_AP = 4;
|
||||
const CATEGORY_OFFLINE = 5;
|
||||
const CATEGORY_UNKNOWN = 6;
|
||||
|
||||
const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
|
||||
|
||||
// HA is not a Matter node; the frontend synthesizes it as the graph root.
|
||||
export const HOME_ASSISTANT_NODE_ID = "ha";
|
||||
const HOME_ASSISTANT_LABEL = "Home Assistant";
|
||||
|
||||
// 0 is never returned: a falsy link value re-enables the direction arrow
|
||||
// in ha-network-graph
|
||||
export const strengthToScale = (
|
||||
strength?: MatterTopologyStrength | null
|
||||
): number => {
|
||||
switch (strength) {
|
||||
case "strong":
|
||||
return 4;
|
||||
case "medium":
|
||||
return 3;
|
||||
case "weak":
|
||||
return 2;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
export const strengthToColorVar = (
|
||||
strength?: MatterTopologyStrength | null
|
||||
): string => {
|
||||
switch (strength) {
|
||||
case "strong":
|
||||
return "--success-color";
|
||||
case "medium":
|
||||
return "--warning-color";
|
||||
case "weak":
|
||||
return "--error-color";
|
||||
default:
|
||||
return "--disabled-color";
|
||||
}
|
||||
};
|
||||
|
||||
const strengthToWidth = (strength?: MatterTopologyStrength | null): number =>
|
||||
strength === "strong" ? 3 : strength === "medium" ? 2 : 1;
|
||||
|
||||
export const getTopologyNodeCategory = (
|
||||
node: MatterNetworkTopologyNode
|
||||
): number => {
|
||||
if (node.kind === "border_router") {
|
||||
return CATEGORY_BORDER_ROUTER;
|
||||
}
|
||||
if (node.kind === "wifi_ap") {
|
||||
return CATEGORY_WIFI_AP;
|
||||
}
|
||||
if (node.kind === "thread_unknown") {
|
||||
return CATEGORY_UNKNOWN;
|
||||
}
|
||||
if (node.available === false) {
|
||||
return CATEGORY_OFFLINE;
|
||||
}
|
||||
return node.role && ROUTER_ROLES.has(node.role)
|
||||
? CATEGORY_ROUTER
|
||||
: CATEGORY_END_DEVICE;
|
||||
};
|
||||
|
||||
export const getTopologyNodeName = (
|
||||
node: MatterNetworkTopologyNode,
|
||||
hass: HomeAssistant
|
||||
): string => {
|
||||
const device = node.ha_device_id
|
||||
? hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
if (device) {
|
||||
return device.name_by_user || device.name || node.id;
|
||||
}
|
||||
if (node.kind === "border_router") {
|
||||
return (
|
||||
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
|
||||
hass.localize("ui.panel.config.matter.visualization.border_router")
|
||||
);
|
||||
}
|
||||
if (node.kind === "wifi_ap") {
|
||||
return (
|
||||
node.network_name ||
|
||||
hass.localize("ui.panel.config.matter.visualization.wifi_ap")
|
||||
);
|
||||
}
|
||||
if (node.kind === "thread_unknown") {
|
||||
return hass.localize("ui.panel.config.matter.visualization.unknown_device");
|
||||
}
|
||||
if (node.node_id != null) {
|
||||
return hass.localize("ui.panel.config.matter.visualization.node", {
|
||||
node_id: node.node_id,
|
||||
});
|
||||
}
|
||||
return node.id;
|
||||
};
|
||||
|
||||
const isHub = (category: number): boolean =>
|
||||
category === CATEGORY_BORDER_ROUTER || category === CATEGORY_WIFI_AP;
|
||||
|
||||
export function createMatterNetworkChartData(
|
||||
topology: MatterNetworkTopology,
|
||||
hass: HomeAssistant,
|
||||
element: Element
|
||||
): NetworkData {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
const categoryColors = [
|
||||
style.getPropertyValue("--primary-color"),
|
||||
style.getPropertyValue("--deep-purple-color"),
|
||||
style.getPropertyValue("--cyan-color"),
|
||||
style.getPropertyValue("--teal-color"),
|
||||
style.getPropertyValue("--indigo-color"),
|
||||
style.getPropertyValue("--error-color"),
|
||||
style.getPropertyValue("--disabled-color"),
|
||||
];
|
||||
const categories = [
|
||||
{
|
||||
name: HOME_ASSISTANT_LABEL,
|
||||
symbol: "roundRect",
|
||||
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.border_router"),
|
||||
symbol: "roundRect",
|
||||
itemStyle: { color: categoryColors[CATEGORY_BORDER_ROUTER] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.router"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_ROUTER] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.end_device"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_END_DEVICE] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.wifi_ap"),
|
||||
symbol: "roundRect",
|
||||
itemStyle: { color: categoryColors[CATEGORY_WIFI_AP] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.offline"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_OFFLINE] },
|
||||
},
|
||||
{
|
||||
name: hass.localize(
|
||||
"ui.panel.config.matter.visualization.unknown_devices"
|
||||
),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_UNKNOWN] },
|
||||
},
|
||||
];
|
||||
|
||||
const threadNetworks = new Set(
|
||||
topology.nodes.map((node) => node.ext_pan_id).filter(Boolean)
|
||||
);
|
||||
const multiNetwork = threadNetworks.size > 1;
|
||||
|
||||
const nodes: NetworkNode[] = [
|
||||
{
|
||||
id: HOME_ASSISTANT_NODE_ID,
|
||||
name: HOME_ASSISTANT_LABEL,
|
||||
category: CATEGORY_HOME_ASSISTANT,
|
||||
value: 4,
|
||||
symbol: "roundRect",
|
||||
symbolSize: 45,
|
||||
polarDistance: 0,
|
||||
fixed: true,
|
||||
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
|
||||
},
|
||||
];
|
||||
const nodeCategories = new Map<string, number>();
|
||||
topology.nodes.forEach((node) => {
|
||||
const category = getTopologyNodeCategory(node);
|
||||
nodeCategories.set(node.id, category);
|
||||
const device = node.ha_device_id
|
||||
? hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
const area = device ? getDeviceArea(device, hass.areas) : undefined;
|
||||
const contextParts: string[] = [];
|
||||
if (area) {
|
||||
contextParts.push(area.name);
|
||||
}
|
||||
if ((multiNetwork || !area) && node.network_name) {
|
||||
contextParts.push(node.network_name);
|
||||
}
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
name: getTopologyNodeName(node, hass),
|
||||
context: contextParts.join(" • ") || undefined,
|
||||
category,
|
||||
value: isHub(category) ? 3 : category === CATEGORY_ROUTER ? 2 : 1,
|
||||
symbol: isHub(category) ? "roundRect" : "circle",
|
||||
symbolSize: isHub(category) ? 40 : category === CATEGORY_ROUTER ? 30 : 20,
|
||||
itemStyle: {
|
||||
color: categoryColors[category],
|
||||
...(node.role === "leader"
|
||||
? {
|
||||
borderColor: style.getPropertyValue("--primary-color"),
|
||||
borderWidth: 2,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
polarDistance: isHub(category)
|
||||
? 0.1
|
||||
: category === CATEGORY_ROUTER
|
||||
? 0.4
|
||||
: 0.8,
|
||||
});
|
||||
});
|
||||
|
||||
const links: NetworkLink[] = [];
|
||||
topology.connections.forEach((conn) => {
|
||||
if (!nodeCategories.has(conn.source) || !nodeCategories.has(conn.target)) {
|
||||
return;
|
||||
}
|
||||
let { source, target } = conn;
|
||||
let forward = conn.source_to_target;
|
||||
let reverse = conn.target_to_source;
|
||||
if (!forward && reverse) {
|
||||
// normalize so the arrow points in the observed direction
|
||||
[source, target] = [target, source];
|
||||
forward = reverse;
|
||||
reverse = undefined;
|
||||
}
|
||||
const oneWay = Boolean(forward) && !reverse;
|
||||
const asymmetric =
|
||||
forward && reverse && forward.strength !== reverse.strength;
|
||||
const width = strengthToWidth(conn.strength);
|
||||
links.push({
|
||||
source,
|
||||
target,
|
||||
value: strengthToScale(forward?.strength ?? conn.strength),
|
||||
// route-table edges without per-direction info are not directional
|
||||
reverseValue: oneWay
|
||||
? undefined
|
||||
: strengthToScale(reverse?.strength ?? conn.strength),
|
||||
symbolSize: oneWay ? width * 2 + 3 : undefined,
|
||||
lineStyle: {
|
||||
width,
|
||||
color: style.getPropertyValue(strengthToColorVar(conn.strength)),
|
||||
type:
|
||||
oneWay || asymmetric
|
||||
? "dashed"
|
||||
: !forward && conn.via_route_table
|
||||
? "dotted"
|
||||
: "solid",
|
||||
},
|
||||
ignoreForceLayout: !(
|
||||
isHub(nodeCategories.get(source)!) || isHub(nodeCategories.get(target)!)
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const haLink = (target: string): NetworkLink => ({
|
||||
source: HOME_ASSISTANT_NODE_ID,
|
||||
target,
|
||||
value: 0,
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: categoryColors[CATEGORY_HOME_ASSISTANT],
|
||||
},
|
||||
});
|
||||
|
||||
// HA reaches the mesh through the border routers and Wi-Fi access points
|
||||
const hubIds = topology.nodes
|
||||
.filter((node) => node.kind === "border_router" || node.kind === "wifi_ap")
|
||||
.map((node) => node.id);
|
||||
hubIds.forEach((id) => links.push(haLink(id)));
|
||||
|
||||
// any node group without a border router / AP is linked straight to HA so
|
||||
// it never floats free (HA has a direct operational path to every node)
|
||||
const adjacency = new Map<string, Set<string>>();
|
||||
topology.nodes.forEach((node) => adjacency.set(node.id, new Set()));
|
||||
links.forEach((link) => {
|
||||
if (link.source === HOME_ASSISTANT_NODE_ID) {
|
||||
return;
|
||||
}
|
||||
adjacency.get(link.source)?.add(link.target);
|
||||
adjacency.get(link.target)?.add(link.source);
|
||||
});
|
||||
const hubIdSet = new Set(hubIds);
|
||||
const visited = new Set<string>();
|
||||
topology.nodes.forEach((startNode) => {
|
||||
if (visited.has(startNode.id)) {
|
||||
return;
|
||||
}
|
||||
const component: string[] = [];
|
||||
const queue = [startNode.id];
|
||||
visited.add(startNode.id);
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!;
|
||||
component.push(id);
|
||||
adjacency.get(id)?.forEach((next) => {
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
queue.push(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (component.some((id) => hubIdSet.has(id))) {
|
||||
return;
|
||||
}
|
||||
const candidates = component.filter(
|
||||
(id) => nodeCategories.get(id) !== CATEGORY_UNKNOWN
|
||||
);
|
||||
const representative = (candidates.length ? candidates : component).sort(
|
||||
(a, b) => (adjacency.get(b)?.size ?? 0) - (adjacency.get(a)?.size ?? 0)
|
||||
)[0];
|
||||
if (representative) {
|
||||
links.push(haLink(representative));
|
||||
}
|
||||
});
|
||||
|
||||
// keep the strongest link of every node in the force layout so
|
||||
// nodes hang near their best connection instead of floating free
|
||||
nodes.forEach((node) => {
|
||||
let bestLink: NetworkLink | undefined;
|
||||
const hasActiveLink = links.some((link) => {
|
||||
if (link.source !== node.id && link.target !== node.id) {
|
||||
return false;
|
||||
}
|
||||
if (!link.ignoreForceLayout) {
|
||||
return true;
|
||||
}
|
||||
const linkValue = Math.max(link.value ?? 0, link.reverseValue ?? 0);
|
||||
if (
|
||||
linkValue >
|
||||
Math.max(bestLink?.value ?? -1, bestLink?.reverseValue ?? -1)
|
||||
) {
|
||||
bestLink = link;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!hasActiveLink && bestLink) {
|
||||
bestLink.ignoreForceLayout = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes, links, categories };
|
||||
}
|
||||
+515
@@ -0,0 +1,515 @@
|
||||
import { mdiRefresh } from "@mdi/js";
|
||||
import type {
|
||||
CallbackDataParams,
|
||||
TopLevelFormatterParams,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { relativeTime } from "../../../../../common/datetime/relative_time";
|
||||
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../../../../common/translations/localize";
|
||||
import { throttle } from "../../../../../common/util/throttle";
|
||||
import "../../../../../components/chart/ha-network-graph";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
|
||||
import type {
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyConnection,
|
||||
MatterNetworkTopologyNode,
|
||||
MatterTopologyDirectionInfo,
|
||||
} from "../../../../../data/matter";
|
||||
import {
|
||||
fetchMatterNetworkTopology,
|
||||
subscribeMatterNetworkTopology,
|
||||
} from "../../../../../data/matter";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import {
|
||||
createMatterNetworkChartData,
|
||||
HOME_ASSISTANT_NODE_ID,
|
||||
} from "./matter-network-data";
|
||||
|
||||
const UPDATE_THROTTLE_TIME = 5000;
|
||||
|
||||
@customElement("matter-network-visualization")
|
||||
export class MatterNetworkVisualization extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _topology?: MatterNetworkTopology;
|
||||
|
||||
@state() private _notSupported = false;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _refreshing = false;
|
||||
|
||||
@state() private _searchFilter = "";
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _throttledUpdateTopology = throttle(
|
||||
(topology: MatterNetworkTopology) => {
|
||||
this._topology = topology;
|
||||
},
|
||||
UPDATE_THROTTLE_TIME
|
||||
);
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.hass && !this._unsub) {
|
||||
this._subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._throttledUpdateTopology.cancel();
|
||||
if (this._unsub) {
|
||||
this._unsub.then((unsub) => unsub()).catch(() => undefined);
|
||||
this._unsub = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _subscribe(): void {
|
||||
this._unsub = subscribeMatterNetworkTopology(this.hass, (topology) => {
|
||||
if (!this._topology) {
|
||||
this._topology = topology;
|
||||
} else {
|
||||
this._throttledUpdateTopology(topology);
|
||||
}
|
||||
});
|
||||
this._unsub.catch((err: { code?: string; message?: string }) => {
|
||||
this._unsub = undefined;
|
||||
if (err?.code === "not_supported" || err?.code === "unknown_command") {
|
||||
this._notSupported = true;
|
||||
} else {
|
||||
this._error = err?.message || String(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.header"
|
||||
)}
|
||||
back-path="/config/matter/dashboard"
|
||||
>
|
||||
${
|
||||
this.narrow && this._topology?.nodes.length
|
||||
? html`<div slot="header">${this._renderInputSearch()}</div>`
|
||||
: nothing
|
||||
}
|
||||
${this._renderContent()}
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderContent() {
|
||||
if (this._notSupported) {
|
||||
return html`<div class="center">
|
||||
<ha-alert alert-type="info">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.not_supported"
|
||||
)}
|
||||
</ha-alert>
|
||||
</div>`;
|
||||
}
|
||||
if (this._error) {
|
||||
return html`<div class="center">
|
||||
<ha-alert alert-type="error">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.error_loading",
|
||||
{ error: this._error }
|
||||
)}
|
||||
</ha-alert>
|
||||
</div>`;
|
||||
}
|
||||
if (!this._topology) {
|
||||
return html`<div class="center"><ha-spinner></ha-spinner></div>`;
|
||||
}
|
||||
if (!this._topology.nodes.length) {
|
||||
return html`<div class="center empty">
|
||||
${this.hass.localize("ui.panel.config.matter.visualization.empty")}
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<ha-network-graph
|
||||
.hass=${this.hass}
|
||||
.searchFilter=${this._searchFilter}
|
||||
.data=${this._formatNetworkData(
|
||||
this._topology,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.themes,
|
||||
this.hass.language
|
||||
)}
|
||||
.searchableAttributes=${this._getSearchableAttributes}
|
||||
.tooltipFormatter=${this._tooltipFormatter}
|
||||
@chart-click=${this._handleChartClick}
|
||||
>
|
||||
${!this.narrow ? this._renderInputSearch("search") : nothing}
|
||||
<ha-icon-button
|
||||
slot="button"
|
||||
class="refresh-button"
|
||||
.disabled=${this._refreshing}
|
||||
.path=${mdiRefresh}
|
||||
@click=${this._refreshTopology}
|
||||
label=${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.refresh_topology"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</ha-network-graph>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderInputSearch(slot = "") {
|
||||
return html`<ha-input-search
|
||||
appearance="outlined"
|
||||
slot=${slot}
|
||||
.value=${this._searchFilter}
|
||||
@input=${this._handleSearchChange}
|
||||
></ha-input-search>`;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: InputEvent): void {
|
||||
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
|
||||
}
|
||||
|
||||
private async _refreshTopology(): Promise<void> {
|
||||
if (this._refreshing) {
|
||||
return;
|
||||
}
|
||||
this._refreshing = true;
|
||||
try {
|
||||
this._topology = await fetchMatterNetworkTopology(this.hass, true);
|
||||
} catch (err: unknown) {
|
||||
this._error = (err as { message?: string })?.message || String(err);
|
||||
} finally {
|
||||
this._refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _formatNetworkData = memoizeOne(
|
||||
(
|
||||
topology: MatterNetworkTopology,
|
||||
_devices: HomeAssistant["devices"],
|
||||
_areas: HomeAssistant["areas"],
|
||||
// node/link colors and labels also depend on the theme and language,
|
||||
// so both take part in the cache key even though they are read via hass
|
||||
_themes: HomeAssistant["themes"],
|
||||
_language: HomeAssistant["language"]
|
||||
) => createMatterNetworkChartData(topology, this.hass, this)
|
||||
);
|
||||
|
||||
private _getTopologyNode(id: string): MatterNetworkTopologyNode | undefined {
|
||||
return this._topology?.nodes.find((node) => node.id === id);
|
||||
}
|
||||
|
||||
private _getConnection(
|
||||
source: string,
|
||||
target: string
|
||||
): MatterNetworkTopologyConnection | undefined {
|
||||
return this._topology?.connections.find(
|
||||
(conn) =>
|
||||
(conn.source === source && conn.target === target) ||
|
||||
(conn.source === target && conn.target === source)
|
||||
);
|
||||
}
|
||||
|
||||
private _getNodeName(id: string): string {
|
||||
const node = this._getTopologyNode(id);
|
||||
if (!node) {
|
||||
return id;
|
||||
}
|
||||
const device = node.ha_device_id
|
||||
? this.hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
if (device) {
|
||||
return device.name_by_user || device.name || id;
|
||||
}
|
||||
if (node.kind === "border_router") {
|
||||
return (
|
||||
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
|
||||
this.hass.localize("ui.panel.config.matter.visualization.border_router")
|
||||
);
|
||||
}
|
||||
if (node.kind === "wifi_ap") {
|
||||
return (
|
||||
node.network_name ||
|
||||
this.hass.localize("ui.panel.config.matter.visualization.wifi_ap")
|
||||
);
|
||||
}
|
||||
if (node.kind === "thread_unknown") {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.unknown_device"
|
||||
);
|
||||
}
|
||||
if (node.node_id != null) {
|
||||
return this.hass.localize("ui.panel.config.matter.visualization.node", {
|
||||
node_id: node.node_id,
|
||||
});
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private _getSearchableAttributes = (nodeId: string): string[] => {
|
||||
const node = this._getTopologyNode(nodeId);
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
const attributes: string[] = [];
|
||||
if (node.node_id != null) {
|
||||
attributes.push(String(node.node_id));
|
||||
}
|
||||
if (node.network_name) {
|
||||
attributes.push(node.network_name);
|
||||
}
|
||||
if (node.ext_address) {
|
||||
attributes.push(node.ext_address);
|
||||
}
|
||||
if (node.vendor_name) {
|
||||
attributes.push(node.vendor_name);
|
||||
}
|
||||
if (node.model_name) {
|
||||
attributes.push(node.model_name);
|
||||
}
|
||||
const device = node.ha_device_id
|
||||
? this.hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
if (device?.manufacturer) {
|
||||
attributes.push(device.manufacturer);
|
||||
}
|
||||
if (device?.model) {
|
||||
attributes.push(device.model);
|
||||
}
|
||||
device?.connections.forEach((connection) => {
|
||||
attributes.push(connection[1]);
|
||||
});
|
||||
return attributes;
|
||||
};
|
||||
|
||||
private _localizeDynamic(prefix: string, value: string): string {
|
||||
return (
|
||||
this.hass.localize(
|
||||
`ui.panel.config.matter.${prefix}.${value}` as LocalizeKeys
|
||||
) || value
|
||||
);
|
||||
}
|
||||
|
||||
private _formatDirection(direction: MatterTopologyDirectionInfo): string {
|
||||
const strength = this._localizeDynamic(
|
||||
"visualization.strength",
|
||||
direction.strength
|
||||
);
|
||||
if (direction.lqi != null) {
|
||||
return `${strength} (LQI ${direction.lqi})`;
|
||||
}
|
||||
if (direction.rssi != null) {
|
||||
return `${strength} (RSSI ${direction.rssi} dBm)`;
|
||||
}
|
||||
return strength;
|
||||
}
|
||||
|
||||
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
|
||||
const { dataType, data } = params as CallbackDataParams;
|
||||
if (dataType === "edge") {
|
||||
const { source, target } = data as { source: string; target: string };
|
||||
const conn = this._getConnection(source, target);
|
||||
if (!conn) {
|
||||
return nothing;
|
||||
}
|
||||
const lines: TemplateResult[] = [];
|
||||
if (conn.source_to_target) {
|
||||
lines.push(
|
||||
html`<br />${this._getNodeName(conn.source)} →
|
||||
${this._getNodeName(conn.target)}:
|
||||
${this._formatDirection(conn.source_to_target)}`
|
||||
);
|
||||
}
|
||||
if (conn.target_to_source) {
|
||||
lines.push(
|
||||
html`<br />${this._getNodeName(conn.target)} →
|
||||
${this._getNodeName(conn.source)}:
|
||||
${this._formatDirection(conn.target_to_source)}`
|
||||
);
|
||||
}
|
||||
if (!lines.length && conn.via_route_table) {
|
||||
lines.push(
|
||||
html`<br />${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.via_route_table"
|
||||
)}`
|
||||
);
|
||||
}
|
||||
return html`<b
|
||||
>${this._getNodeName(conn.source)} ↔
|
||||
${this._getNodeName(conn.target)}</b
|
||||
>${lines}`;
|
||||
}
|
||||
const { id } = data as { id: string };
|
||||
if (id === HOME_ASSISTANT_NODE_ID) {
|
||||
return html`<b>Home Assistant</b>`;
|
||||
}
|
||||
const node = this._getTopologyNode(id);
|
||||
if (!node) {
|
||||
return nothing;
|
||||
}
|
||||
const device = node.ha_device_id
|
||||
? this.hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
|
||||
const lines: TemplateResult[] = [];
|
||||
if (node.node_id != null) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.node_id"
|
||||
)}:</b
|
||||
>
|
||||
${node.node_id}`
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.network"
|
||||
)}:</b
|
||||
>
|
||||
${this._localizeDynamic("network_type", node.network_type)}${
|
||||
node.network_name ? html` (${node.network_name})` : nothing
|
||||
}`
|
||||
);
|
||||
if (node.role) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.role"
|
||||
)}:</b
|
||||
>
|
||||
${this._localizeDynamic("visualization.roles", node.role)}`
|
||||
);
|
||||
}
|
||||
if (node.available != null) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.status"
|
||||
)}:</b
|
||||
>
|
||||
${this.hass.localize(
|
||||
node.available
|
||||
? "ui.panel.config.matter.visualization.online"
|
||||
: "ui.panel.config.matter.visualization.offline"
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (device?.manufacturer || node.vendor_name) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.manufacturer"
|
||||
)}:</b
|
||||
>
|
||||
${device?.manufacturer || node.vendor_name}`
|
||||
);
|
||||
}
|
||||
if (device?.model || node.model_name) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.model"
|
||||
)}:</b
|
||||
>
|
||||
${device?.model || node.model_name}`
|
||||
);
|
||||
}
|
||||
if (area) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.area"
|
||||
)}:</b
|
||||
>
|
||||
${area.name}`
|
||||
);
|
||||
}
|
||||
if (node.last_seen != null) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.last_seen"
|
||||
)}:</b
|
||||
>
|
||||
${relativeTime(new Date(node.last_seen), this.hass.locale)}`
|
||||
);
|
||||
}
|
||||
return html`<b>${this._getNodeName(id)}</b>${lines}`;
|
||||
};
|
||||
|
||||
private _handleChartClick(e: CustomEvent): void {
|
||||
if (
|
||||
e.detail.dataType === "node" &&
|
||||
e.detail.event.target.cursor === "pointer"
|
||||
) {
|
||||
const { id } = e.detail.data;
|
||||
const node = this._getTopologyNode(id);
|
||||
if (node?.ha_device_id) {
|
||||
navigate(`/config/devices/device/${node.ha_device_id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
ha-network-graph {
|
||||
height: 100%;
|
||||
}
|
||||
[slot="header"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
ha-input-search {
|
||||
flex: 1;
|
||||
}
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
ha-alert {
|
||||
max-width: 500px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"matter-network-visualization": MatterNetworkVisualization;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,30 @@ const MEASUREMENT_VARIANTS: Variant[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const MEASUREMENT_ANGLE_VARIANTS: Variant[] = [
|
||||
{
|
||||
labelKey: "last_24h",
|
||||
days_to_show: 1,
|
||||
period: "hour",
|
||||
chart_type: "line",
|
||||
stat_types: ["mean"],
|
||||
},
|
||||
{
|
||||
labelKey: "last_7d",
|
||||
days_to_show: 7,
|
||||
period: "day",
|
||||
chart_type: "line",
|
||||
stat_types: ["mean"],
|
||||
},
|
||||
{
|
||||
labelKey: "last_30d",
|
||||
days_to_show: 30,
|
||||
period: "day",
|
||||
chart_type: "line",
|
||||
stat_types: ["mean"],
|
||||
},
|
||||
];
|
||||
|
||||
const TOTAL_VARIANTS: Variant[] = [
|
||||
{
|
||||
labelKey: "last_7d",
|
||||
@@ -60,6 +84,13 @@ const TOTAL_VARIANTS: Variant[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const VARIANTS_BY_STATE_CLASS: Record<string, Variant[]> = {
|
||||
measurement: MEASUREMENT_VARIANTS,
|
||||
measurement_angle: MEASUREMENT_ANGLE_VARIANTS,
|
||||
total: TOTAL_VARIANTS,
|
||||
total_increasing: TOTAL_VARIANTS,
|
||||
};
|
||||
|
||||
export const statisticsGraphCardSuggestions: CardSuggestionProvider<StatisticsGraphCardConfig> =
|
||||
{
|
||||
getEntitySuggestion(hass, entityId) {
|
||||
@@ -67,8 +98,8 @@ export const statisticsGraphCardSuggestions: CardSuggestionProvider<StatisticsGr
|
||||
const stateObj = hass.states[entityId];
|
||||
const stateClass = stateObj?.attributes.state_class;
|
||||
if (!stateClass) return null;
|
||||
const variants =
|
||||
stateClass === "measurement" ? MEASUREMENT_VARIANTS : TOTAL_VARIANTS;
|
||||
const variants = VARIANTS_BY_STATE_CLASS[stateClass];
|
||||
if (!variants) return null;
|
||||
const suggestions: CardSuggestion<StatisticsGraphCardConfig>[] =
|
||||
variants.map((v) => ({
|
||||
label: hass.localize(`${LABEL_PREFIX}${v.labelKey}` as any),
|
||||
|
||||
@@ -521,7 +521,7 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
true,
|
||||
generateFillBuckets(datasets, start, end, getSuggestedPeriod(start, end))
|
||||
);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
|
||||
|
||||
return {
|
||||
chartData: datasets,
|
||||
|
||||
@@ -119,7 +119,7 @@ export function generateEnergyGasGraphData(
|
||||
true,
|
||||
generateFillBuckets(datasets, start, end, period)
|
||||
);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
|
||||
const chartData = datasets;
|
||||
const total = processTotal(energyData.stats, gasSources);
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ export function generateEnergySolarGraphData(
|
||||
end,
|
||||
compareStart,
|
||||
compareEnd,
|
||||
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax),
|
||||
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax, true),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ const stackOrder = {
|
||||
to_grid: 2,
|
||||
used_solar: 3,
|
||||
used_battery: 4,
|
||||
from_grid: 5,
|
||||
used_grid: 5,
|
||||
};
|
||||
|
||||
@customElement("hui-energy-usage-graph-card")
|
||||
@@ -295,6 +297,15 @@ export class HuiEnergyUsageGraphCard
|
||||
to_battery: {},
|
||||
};
|
||||
|
||||
// Grid sources can be import-only or export-only; assign color indices by
|
||||
// position in the user's grid sources config so this card matches the
|
||||
// energy sources table, which uses positional indices.
|
||||
const colorIndices: Record<string, Record<string, number>> = {};
|
||||
Object.keys(colorPropertyMap).forEach((key) => {
|
||||
colorIndices[key] = {};
|
||||
});
|
||||
let gridIdx = 0;
|
||||
|
||||
for (const source of energyData.prefs.energy_sources) {
|
||||
if (source.type === "solar") {
|
||||
if (statIds.solar) {
|
||||
@@ -333,6 +344,7 @@ export class HuiEnergyUsageGraphCard
|
||||
} else {
|
||||
statIds.from_grid = [gridSource.stat_energy_from];
|
||||
}
|
||||
colorIndices.from_grid[gridSource.stat_energy_from] = gridIdx;
|
||||
if (gridSource.name) {
|
||||
statLabels.from_grid[gridSource.stat_energy_from] =
|
||||
gridSource.stat_energy_to
|
||||
@@ -349,6 +361,7 @@ export class HuiEnergyUsageGraphCard
|
||||
} else {
|
||||
statIds.to_grid = [gridSource.stat_energy_to];
|
||||
}
|
||||
colorIndices.to_grid[gridSource.stat_energy_to] = gridIdx;
|
||||
if (gridSource.name) {
|
||||
statLabels.to_grid[gridSource.stat_energy_to] =
|
||||
gridSource.stat_energy_from
|
||||
@@ -359,17 +372,18 @@ export class HuiEnergyUsageGraphCard
|
||||
: gridSource.name;
|
||||
}
|
||||
}
|
||||
gridIdx++;
|
||||
}
|
||||
|
||||
const computedStyles = getComputedStyle(this);
|
||||
|
||||
const colorIndices: Record<string, Record<string, number>> = {};
|
||||
Object.keys(colorPropertyMap).forEach((key) => {
|
||||
colorIndices[key] = {};
|
||||
if (
|
||||
key === "used_grid" ||
|
||||
key === "used_solar" ||
|
||||
key === "used_battery"
|
||||
key === "used_battery" ||
|
||||
key === "from_grid" ||
|
||||
key === "to_grid"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -461,7 +475,7 @@ export class HuiEnergyUsageGraphCard
|
||||
getSuggestedPeriod(this._start, this._end)
|
||||
)
|
||||
);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
|
||||
this._chartData = datasets;
|
||||
this._legendData = this._getLegendData(datasets);
|
||||
this._total = this._processTotal(consumption);
|
||||
|
||||
@@ -274,7 +274,7 @@ export class HuiEnergyWaterGraphCard
|
||||
getSuggestedPeriod(this._start, this._end)
|
||||
)
|
||||
);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
|
||||
this._chartData = datasets;
|
||||
this._total = this._processTotal(energyData.stats, waterSources);
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ export function generatePowerSourcesGraphData(
|
||||
const end = energyData.end || endOfToday();
|
||||
|
||||
const chartData = fillLineGaps(datasets);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
|
||||
|
||||
const usageData: NonNullable<LineSeriesOption["data"]> = [];
|
||||
// fillLineGaps ensures all datasets share the same x values, so iterate the
|
||||
|
||||
@@ -8,6 +8,7 @@ import "../../../components/ha-card";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { computeCardSize } from "../common/compute-card-size";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { applyDefaultColor } from "../common/entity-color-config";
|
||||
import { processConfigEntities } from "../common/process-config-entities";
|
||||
import "../components/hui-entities-toggle";
|
||||
import { createHeaderFooterElement } from "../create-element/create-header-footer-element";
|
||||
@@ -24,7 +25,7 @@ import type {
|
||||
LovelaceHeaderFooter,
|
||||
} from "../types";
|
||||
import { migrateEntitiesCardConfig } from "./migrate-card-config";
|
||||
import type { EntitiesCardConfig } from "./types";
|
||||
import type { EntitiesCardConfig, EntitiesCardEntityConfig } from "./types";
|
||||
import { haStyleScrollbar } from "../../../resources/styles";
|
||||
|
||||
export const computeShowHeaderToggle = <
|
||||
@@ -159,7 +160,7 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
|
||||
const migratedConfig = migrateEntitiesCardConfig(config);
|
||||
const entities = processConfigEntities(migratedConfig.entities);
|
||||
|
||||
this._config = migratedConfig;
|
||||
this._config = { color: "state", ...migratedConfig };
|
||||
this._configEntities = entities;
|
||||
this._showHeaderToggle = computeShowHeaderToggle(migratedConfig, entities);
|
||||
if (this._config.header) {
|
||||
@@ -343,18 +344,18 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
|
||||
`,
|
||||
];
|
||||
|
||||
private _renderEntity(entityConf: LovelaceRowConfig): TemplateResult {
|
||||
const element = createRowElement(
|
||||
(!("type" in entityConf) || entityConf.type === "conditional") &&
|
||||
"state_color" in this._config!
|
||||
? ({
|
||||
state_color: this._config.state_color,
|
||||
...(entityConf as EntityConfig),
|
||||
} as EntityConfig)
|
||||
: entityConf.type === "perform-action"
|
||||
? { ...entityConf, type: "call-service" }
|
||||
: entityConf
|
||||
private _rowConfig(entityConf: LovelaceRowConfig): LovelaceRowConfig {
|
||||
if (entityConf.type === "perform-action") {
|
||||
return { ...entityConf, type: "call-service" };
|
||||
}
|
||||
return applyDefaultColor(
|
||||
entityConf as EntitiesCardEntityConfig,
|
||||
this._config!.color
|
||||
);
|
||||
}
|
||||
|
||||
private _renderEntity(entityConf: LovelaceRowConfig): TemplateResult {
|
||||
const element = createRowElement(this._rowConfig(entityConf));
|
||||
if (this._hass) {
|
||||
element.hass = this._hass;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { findEntities } from "../common/find-entities";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
import { hasAction, hasAnyAction } from "../common/has-action";
|
||||
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
|
||||
import { applyDefaultColor } from "../common/entity-color-config";
|
||||
import { processConfigEntities } from "../common/process-config-entities";
|
||||
import "../components/hui-timestamp-display";
|
||||
import { createEntityNotFoundWarning } from "../components/hui-warning";
|
||||
@@ -87,14 +88,19 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
|
||||
show_name: true,
|
||||
show_state: true,
|
||||
show_icon: true,
|
||||
state_color: true,
|
||||
color: "state",
|
||||
...migratedConfig,
|
||||
};
|
||||
const cardColor = this._config.color;
|
||||
const entities = processConfigEntities(migratedConfig.entities).map(
|
||||
(entityConf) => ({
|
||||
hold_action: { action: "more-info" } as MoreInfoActionConfig,
|
||||
...entityConf,
|
||||
})
|
||||
(entityConf) =>
|
||||
applyDefaultColor(
|
||||
{
|
||||
hold_action: { action: "more-info" } as MoreInfoActionConfig,
|
||||
...entityConf,
|
||||
},
|
||||
cardColor
|
||||
)
|
||||
);
|
||||
|
||||
for (const entity of entities) {
|
||||
@@ -294,9 +300,7 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
|
||||
.stateObj=${stateObj}
|
||||
.overrideIcon=${entityConf.icon}
|
||||
.overrideImage=${entityConf.image}
|
||||
.stateColor=${
|
||||
entityConf.state_color ?? this._config!.state_color
|
||||
}
|
||||
.color=${entityConf.color}
|
||||
></state-badge>
|
||||
`
|
||||
: ""
|
||||
|
||||
@@ -14,6 +14,7 @@ import { forwardHaptic } from "../../../data/haptics";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
import type { ToggleGroupCardConfig } from "./types";
|
||||
import { getToggleAction } from "../../../common/entity/get_toggle_action";
|
||||
|
||||
@customElement("hui-toggle-group-card")
|
||||
export class HuiToggleGroupCard extends LitElement implements LovelaceCard {
|
||||
@@ -95,12 +96,7 @@ export class HuiToggleGroupCard extends LitElement implements LovelaceCard {
|
||||
const onEntities = this._getOnEntities();
|
||||
const domain = computeDomain(this._config.entities[0]);
|
||||
|
||||
let service: string;
|
||||
if (domain === "cover") {
|
||||
service = onEntities.length > 0 ? "close_cover" : "open_cover";
|
||||
} else {
|
||||
service = onEntities.length > 0 ? "turn_off" : "turn_on";
|
||||
}
|
||||
const service = getToggleAction(domain, onEntities.length === 0);
|
||||
|
||||
this.hass.callService(domain, service, {
|
||||
entity_id: this._config.entities,
|
||||
|
||||
@@ -1,40 +1,56 @@
|
||||
import type { EntityConfig, LovelaceRowConfig } from "../entity-rows/types";
|
||||
import { migrateStateColorConfig } from "../common/entity-color-config";
|
||||
import { migrateTimeFormatConfig } from "../common/entity-time-format-config";
|
||||
import type {
|
||||
ConditionalRowConfig,
|
||||
LovelaceRowConfig,
|
||||
} from "../entity-rows/types";
|
||||
import type {
|
||||
EntitiesCardConfig,
|
||||
EntitiesCardEntityConfig,
|
||||
GlanceCardConfig,
|
||||
GlanceConfigEntity,
|
||||
} from "./types";
|
||||
|
||||
const migrateEntitiesRowConfig = (
|
||||
rowConf: LovelaceRowConfig | string
|
||||
): LovelaceRowConfig | string => {
|
||||
if (typeof rowConf !== "object") {
|
||||
return rowConf;
|
||||
}
|
||||
let newConf: LovelaceRowConfig = rowConf;
|
||||
newConf = migrateTimeFormatConfig(newConf as EntitiesCardEntityConfig);
|
||||
newConf = migrateStateColorConfig(newConf as EntitiesCardEntityConfig);
|
||||
if (newConf.type === "conditional") {
|
||||
const row = (newConf as ConditionalRowConfig).row;
|
||||
if (row && typeof row === "object") {
|
||||
let newRow = migrateTimeFormatConfig(row as EntitiesCardEntityConfig);
|
||||
newRow = migrateStateColorConfig(newRow);
|
||||
if (newRow !== row) {
|
||||
newConf = { ...newConf, row: newRow } as ConditionalRowConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
return newConf;
|
||||
};
|
||||
|
||||
export const migrateEntitiesCardConfig = (
|
||||
config: EntitiesCardConfig
|
||||
): EntitiesCardConfig => {
|
||||
let changed = false;
|
||||
const newEntities = config.entities?.map((e) => {
|
||||
if (typeof e !== "object") {
|
||||
return e;
|
||||
const newConf = migrateEntitiesRowConfig(e);
|
||||
if (newConf !== e) {
|
||||
changed = true;
|
||||
}
|
||||
// Custom rows own their config schema and may use `format` with a
|
||||
// different meaning (e.g. custom:multiple-entity-row), so leave it
|
||||
// untouched.
|
||||
if (e.type?.startsWith("custom:")) {
|
||||
return e;
|
||||
}
|
||||
if (!("format" in e)) {
|
||||
return e;
|
||||
}
|
||||
changed = true;
|
||||
const { format, ...rest } = e;
|
||||
return {
|
||||
...rest,
|
||||
time_format: (rest as EntityConfig).time_format ?? format,
|
||||
};
|
||||
return newConf;
|
||||
});
|
||||
const newConfig = migrateStateColorConfig(config);
|
||||
if (!changed) {
|
||||
return config;
|
||||
return newConfig;
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
entities: newEntities as (LovelaceRowConfig | string)[],
|
||||
...newConfig,
|
||||
entities: newEntities,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -46,21 +62,20 @@ export const migrateGlanceCardConfig = (
|
||||
if (typeof e !== "object") {
|
||||
return e;
|
||||
}
|
||||
if (!("format" in e)) {
|
||||
return e;
|
||||
let newConf = e;
|
||||
newConf = migrateTimeFormatConfig(newConf);
|
||||
newConf = migrateStateColorConfig(newConf);
|
||||
if (newConf !== e) {
|
||||
changed = true;
|
||||
}
|
||||
changed = true;
|
||||
const { format, ...rest } = e;
|
||||
return {
|
||||
...rest,
|
||||
time_format: rest.time_format ?? format,
|
||||
};
|
||||
return newConf;
|
||||
});
|
||||
const newConfig = migrateStateColorConfig(config);
|
||||
if (!changed) {
|
||||
return config;
|
||||
return newConfig;
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
...newConfig,
|
||||
entities: newEntities as (GlanceConfigEntity | string)[],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -112,7 +112,9 @@ export interface EntitiesCardEntityConfig extends EntityConfig {
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
double_tap_action?: ActionConfig;
|
||||
/** @deprecated use `color` instead */
|
||||
state_color?: boolean;
|
||||
color?: string;
|
||||
show_name?: boolean;
|
||||
show_icon?: boolean;
|
||||
}
|
||||
@@ -126,7 +128,9 @@ export interface EntitiesCardConfig extends LovelaceCardConfig {
|
||||
icon?: string;
|
||||
header?: LovelaceHeaderFooterConfig;
|
||||
footer?: LovelaceHeaderFooterConfig;
|
||||
/** @deprecated use `color` instead */
|
||||
state_color?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export type AreaCardDisplayType = "compact" | "icon" | "picture" | "camera";
|
||||
@@ -330,7 +334,9 @@ export interface GlanceConfigEntity extends ConfigEntity {
|
||||
show_last_changed?: boolean;
|
||||
image?: string;
|
||||
show_state?: boolean;
|
||||
/** @deprecated use `color` instead */
|
||||
state_color?: boolean;
|
||||
color?: string;
|
||||
time_format?: TimestampRenderingFormat;
|
||||
}
|
||||
|
||||
@@ -342,7 +348,9 @@ export interface GlanceCardConfig extends LovelaceCardConfig {
|
||||
theme?: string;
|
||||
entities: (string | GlanceConfigEntity)[];
|
||||
columns?: number;
|
||||
/** @deprecated use `color` instead */
|
||||
state_color?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface HumidifierCardConfig extends LovelaceCardConfig {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { isCustomType } from "../../../data/lovelace_custom_cards";
|
||||
|
||||
interface LegacyStateColorConfig {
|
||||
type?: string;
|
||||
color?: string;
|
||||
state_color?: boolean;
|
||||
}
|
||||
|
||||
export const migrateStateColorConfig = <T extends LegacyStateColorConfig>(
|
||||
config: T
|
||||
): T => {
|
||||
// Custom elements own their config schema, leave them untouched
|
||||
if (config.type !== undefined && isCustomType(config.type)) {
|
||||
return config;
|
||||
}
|
||||
if (config.state_color === undefined) {
|
||||
return config;
|
||||
}
|
||||
const { state_color, ...rest } = config;
|
||||
return {
|
||||
color: state_color ? "state" : "none",
|
||||
...rest,
|
||||
} as T;
|
||||
};
|
||||
|
||||
export const applyDefaultColor = <T extends { type?: string; color?: string }>(
|
||||
config: T,
|
||||
color: string | undefined
|
||||
): T =>
|
||||
color === undefined ||
|
||||
config.color !== undefined ||
|
||||
(config.type !== undefined && isCustomType(config.type))
|
||||
? config
|
||||
: { ...config, color };
|
||||
@@ -0,0 +1,27 @@
|
||||
import { isCustomType } from "../../../data/lovelace_custom_cards";
|
||||
import type { TimestampRenderingFormat } from "../components/types";
|
||||
|
||||
interface LegacyTimeFormatConfig {
|
||||
type?: string;
|
||||
time_format?: TimestampRenderingFormat;
|
||||
/** @deprecated use `time_format` instead */
|
||||
format?: TimestampRenderingFormat;
|
||||
}
|
||||
|
||||
export const migrateTimeFormatConfig = <T extends LegacyTimeFormatConfig>(
|
||||
config: T
|
||||
): T => {
|
||||
// Custom elements own their config schema and may use the same option with
|
||||
// a different meaning (e.g. custom:multiple-entity-row), leave them untouched
|
||||
if (config.type !== undefined && isCustomType(config.type)) {
|
||||
return config;
|
||||
}
|
||||
if (config.format === undefined) {
|
||||
return config;
|
||||
}
|
||||
const { format, ...rest } = config;
|
||||
return {
|
||||
time_format: format,
|
||||
...rest,
|
||||
} as T;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { STATES_OFF } from "../../../../common/const";
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { getToggleAction } from "../../../../common/entity/get_toggle_action";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
|
||||
export const turnOnOffEntities = (
|
||||
@@ -11,9 +12,11 @@ export const turnOnOffEntities = (
|
||||
entityIds.forEach((entityId) => {
|
||||
if (STATES_OFF.includes(hass.states[entityId].state) === turnOn) {
|
||||
const stateDomain = computeDomain(entityId);
|
||||
const serviceDomain = ["cover", "lock"].includes(stateDomain)
|
||||
? stateDomain
|
||||
: "homeassistant";
|
||||
// Entities with non-standard toggle action need separate calls
|
||||
const serviceDomain =
|
||||
getToggleAction(stateDomain, true) !== "turn_on"
|
||||
? stateDomain
|
||||
: "homeassistant";
|
||||
|
||||
if (!(serviceDomain in domainsToCall)) {
|
||||
domainsToCall[serviceDomain] = [];
|
||||
@@ -23,17 +26,7 @@ export const turnOnOffEntities = (
|
||||
});
|
||||
|
||||
Object.keys(domainsToCall).forEach((domain) => {
|
||||
let service;
|
||||
switch (domain) {
|
||||
case "lock":
|
||||
service = turnOn ? "unlock" : "lock";
|
||||
break;
|
||||
case "cover":
|
||||
service = turnOn ? "open_cover" : "close_cover";
|
||||
break;
|
||||
default:
|
||||
service = turnOn ? "turn_on" : "turn_off";
|
||||
}
|
||||
const service = getToggleAction(domain, turnOn);
|
||||
|
||||
const entities = domainsToCall[domain];
|
||||
hass.callService(domain, service, { entity_id: entities });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { getToggleAction } from "../../../../common/entity/get_toggle_action";
|
||||
import type { HomeAssistant, ServiceCallResponse } from "../../../../types";
|
||||
|
||||
export const turnOnOffEntity = (
|
||||
@@ -9,27 +10,7 @@ export const turnOnOffEntity = (
|
||||
const stateDomain = computeDomain(entityId);
|
||||
const serviceDomain = stateDomain === "group" ? "homeassistant" : stateDomain;
|
||||
|
||||
let service;
|
||||
switch (stateDomain) {
|
||||
case "lock":
|
||||
service = turnOn ? "unlock" : "lock";
|
||||
break;
|
||||
case "cover":
|
||||
service = turnOn ? "open_cover" : "close_cover";
|
||||
break;
|
||||
case "button":
|
||||
case "input_button":
|
||||
service = "press";
|
||||
break;
|
||||
case "scene":
|
||||
service = "turn_on";
|
||||
break;
|
||||
case "valve":
|
||||
service = turnOn ? "open_valve" : "close_valve";
|
||||
break;
|
||||
default:
|
||||
service = turnOn ? "turn_on" : "turn_off";
|
||||
}
|
||||
const service = getToggleAction(stateDomain, turnOn);
|
||||
|
||||
return hass.callService(serviceDomain, service, { entity_id: entityId });
|
||||
};
|
||||
|
||||
@@ -22,6 +22,9 @@ import type {
|
||||
} from "../../../data/lovelace/config/action";
|
||||
import type { ServiceAction } from "../../../data/script";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { canToggleState } from "../../../common/entity/can_toggle_state";
|
||||
import { canToggleDomain } from "../../../common/entity/can_toggle_domain";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
|
||||
export type UiAction = Exclude<ActionConfig["action"], "fire-dom-event">;
|
||||
|
||||
@@ -134,15 +137,35 @@ export class HuiActionEditor extends LitElement {
|
||||
]
|
||||
);
|
||||
|
||||
private _filterToggleAction = memoizeOne(
|
||||
(actions: UiAction[]): UiAction[] => {
|
||||
return actions.filter((a) => a !== "toggle");
|
||||
}
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const actions = this.actions ?? DEFAULT_ACTIONS;
|
||||
|
||||
let action = this.config?.action || (this.required ? "" : "default");
|
||||
|
||||
let actions = this.actions ?? DEFAULT_ACTIONS;
|
||||
|
||||
if (
|
||||
this.context?.entity_id &&
|
||||
action !== "toggle" &&
|
||||
actions.includes("toggle")
|
||||
) {
|
||||
const stateObj = this.hass.states[this.context.entity_id];
|
||||
const canToggle = stateObj
|
||||
? canToggleState(this.hass, stateObj)
|
||||
: canToggleDomain(this.hass, computeDomain(this.context.entity_id));
|
||||
if (!canToggle) {
|
||||
actions = this._filterToggleAction(actions);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "call-service") {
|
||||
action = "perform-action";
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ export class HuiGenericEntityRow extends LitElement {
|
||||
.overrideIcon=${this.config.icon}
|
||||
.overrideImage=${this.config.image}
|
||||
.stateColor=${this.config.state_color}
|
||||
.color=${this.config.color}
|
||||
></state-badge>
|
||||
${
|
||||
!this.hideName
|
||||
|
||||
@@ -2,13 +2,11 @@ import type { HaFormSchema } from "../../../../components/ha-form/types";
|
||||
|
||||
interface CustomizableListSchemaParams {
|
||||
field: string;
|
||||
customize: boolean;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const customizableListSchema = ({
|
||||
field,
|
||||
customize,
|
||||
options,
|
||||
}: CustomizableListSchemaParams) =>
|
||||
[
|
||||
@@ -16,21 +14,18 @@ export const customizableListSchema = ({
|
||||
name: "customize",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
...(customize
|
||||
? ([
|
||||
{
|
||||
name: field,
|
||||
selector: {
|
||||
select: {
|
||||
mode: "list",
|
||||
reorder: true,
|
||||
multiple: true,
|
||||
options,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: field,
|
||||
visible: { field: "customize", value: true },
|
||||
selector: {
|
||||
select: {
|
||||
mode: "list",
|
||||
reorder: true,
|
||||
multiple: true,
|
||||
options,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[];
|
||||
|
||||
// `customize` is form-only and never stored in the config.
|
||||
|
||||
@@ -37,11 +37,7 @@ export class HuiAlarmModesCardFeatureEditor
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
stateObj: HassEntity | undefined,
|
||||
customizeModes: boolean
|
||||
) =>
|
||||
(localize: LocalizeFunc, stateObj: HassEntity | undefined) =>
|
||||
[
|
||||
{
|
||||
name: "customize_modes",
|
||||
@@ -49,27 +45,24 @@ export class HuiAlarmModesCardFeatureEditor
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
...(customizeModes
|
||||
? ([
|
||||
{
|
||||
name: "modes",
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
reorder: true,
|
||||
options: stateObj
|
||||
? supportedAlarmModes(stateObj).map((mode) => ({
|
||||
value: mode,
|
||||
label: `${localize(
|
||||
`ui.panel.lovelace.editor.features.types.alarm-modes.modes_list.${mode}`
|
||||
)}`,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: "modes",
|
||||
visible: { field: "customize_modes", value: true },
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
reorder: true,
|
||||
options: stateObj
|
||||
? supportedAlarmModes(stateObj).map((mode) => ({
|
||||
value: mode,
|
||||
label: `${localize(
|
||||
`ui.panel.lovelace.editor.features.types.alarm-modes.modes_list.${mode}`
|
||||
)}`,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
@@ -87,11 +80,7 @@ export class HuiAlarmModesCardFeatureEditor
|
||||
? this.hass.states[this.context?.entity_id]
|
||||
: undefined;
|
||||
|
||||
const schema = this._schema(
|
||||
this.hass.localize,
|
||||
stateObj,
|
||||
data.customize_modes
|
||||
);
|
||||
const schema = this._schema(this.hass.localize, stateObj);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
@@ -113,9 +113,8 @@ export class HuiClockCardEditor
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
hidden: {
|
||||
visible: {
|
||||
field: "clock_style",
|
||||
operator: "not_eq",
|
||||
value: "digital",
|
||||
},
|
||||
selector: {
|
||||
@@ -132,7 +131,7 @@ export class HuiClockCardEditor
|
||||
},
|
||||
{
|
||||
name: "border",
|
||||
hidden: { field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
visible: { field: "clock_style", value: "analog" },
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.description`
|
||||
@@ -145,7 +144,7 @@ export class HuiClockCardEditor
|
||||
},
|
||||
{
|
||||
name: "ticks",
|
||||
hidden: { field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
visible: { field: "clock_style", value: "analog" },
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.description`
|
||||
@@ -169,13 +168,10 @@ export class HuiClockCardEditor
|
||||
},
|
||||
{
|
||||
name: "seconds_motion",
|
||||
hidden: {
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
{ field: "show_seconds", operator: "not_eq", value: true },
|
||||
],
|
||||
},
|
||||
visible: [
|
||||
{ field: "clock_style", value: "analog" },
|
||||
{ field: "show_seconds", value: true },
|
||||
],
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
|
||||
@@ -199,13 +195,10 @@ export class HuiClockCardEditor
|
||||
},
|
||||
{
|
||||
name: "face_style",
|
||||
hidden: {
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
{ field: "ticks", value: "none" },
|
||||
],
|
||||
},
|
||||
visible: [
|
||||
{ field: "clock_style", value: "analog" },
|
||||
{ field: "ticks", operator: "not_eq", value: "none" },
|
||||
],
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.description`
|
||||
|
||||
+4
-4
@@ -2,6 +2,7 @@ import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -32,13 +33,12 @@ export class HuiCounterActionsCardFeatureEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne((customize: boolean) =>
|
||||
private _schema = memoizeOne((localize: LocalizeFunc) =>
|
||||
customizableListSchema({
|
||||
field: "actions",
|
||||
customize,
|
||||
options: COUNTER_ACTIONS.map((action) => ({
|
||||
value: action,
|
||||
label: this.hass!.localize(
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.features.types.counter-actions.actions_list.${action}`
|
||||
),
|
||||
})),
|
||||
@@ -51,7 +51,7 @@ export class HuiCounterActionsCardFeatureEditor
|
||||
}
|
||||
|
||||
const data = customizableListData(this._config, "actions");
|
||||
const schema = this._schema(data.customize);
|
||||
const schema = this._schema(this.hass.localize);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
@@ -61,38 +61,36 @@ export class HuiEnergyDevicesCardEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne((localize: LocalizeFunc, type: string) => {
|
||||
private _schema = memoizeOne((localize: LocalizeFunc) => {
|
||||
const schema: HaFormSchema[] = [
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
...(type === "energy-devices-graph"
|
||||
? ([
|
||||
{
|
||||
name: "modes",
|
||||
required: false,
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
mode: "list",
|
||||
options: chartModeOpts.map((mode) => ({
|
||||
value: mode,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.energy-devices-graph.mode_options.${mode}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide_compound_stats",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: "modes",
|
||||
required: false,
|
||||
visible: { field: "type", value: "energy-devices-graph" },
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
mode: "list",
|
||||
options: chartModeOpts.map((mode) => ({
|
||||
value: mode,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.energy-devices-graph.mode_options.${mode}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide_compound_stats",
|
||||
required: false,
|
||||
visible: { field: "type", value: "energy-devices-graph" },
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "max_devices",
|
||||
required: false,
|
||||
@@ -114,7 +112,7 @@ export class HuiEnergyDevicesCardEditor
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const schema = this._schema(this.hass.localize, this._config.type);
|
||||
const schema = this._schema(this.hass.localize);
|
||||
|
||||
const data = {
|
||||
...this._config,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
string,
|
||||
union,
|
||||
} from "superstruct";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { HaFormSchema } from "../../../../components/ha-form/types";
|
||||
@@ -24,6 +23,36 @@ import type {
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
|
||||
const SCHEMA: HaFormSchema[] = [
|
||||
{
|
||||
name: "title",
|
||||
visible: { field: "type", operator: "not_eq", value: "energy-compare" },
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "show_legend",
|
||||
visible: {
|
||||
field: "type",
|
||||
operator: "in",
|
||||
value: ["power-sources-graph", "energy-usage-graph"],
|
||||
},
|
||||
default: true,
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "link_dashboard",
|
||||
visible: { field: "type", value: "energy-distribution" },
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
name: "collection_key",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
const cardConfigStruct = assign(
|
||||
baseLovelaceCardConfig,
|
||||
object({
|
||||
@@ -72,46 +101,11 @@ export class HuiEnergyGraphCardEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne((type: string) => {
|
||||
const schema: HaFormSchema[] = [
|
||||
...(type !== "energy-compare"
|
||||
? [{ name: "title", selector: { text: {} } }]
|
||||
: []),
|
||||
...(type === "power-sources-graph" || type === "energy-usage-graph"
|
||||
? [
|
||||
{
|
||||
name: "show_legend",
|
||||
default: true,
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(type === "energy-distribution"
|
||||
? [
|
||||
{
|
||||
name: "link_dashboard",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: "string",
|
||||
name: "collection_key",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
return schema;
|
||||
});
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const schema = this._schema(this._config.type);
|
||||
|
||||
const data = {
|
||||
...this._config,
|
||||
};
|
||||
@@ -119,7 +113,7 @@ export class HuiEnergyGraphCardEditor
|
||||
return html` <ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${schema}
|
||||
.schema=${SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
|
||||
@@ -22,11 +22,9 @@ import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { customType } from "../../../../common/structs/is-custom-type";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-formfield";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import "../../../../components/ha-icon";
|
||||
import "../../../../components/ha-switch";
|
||||
import "../../../../components/ha-theme-picker";
|
||||
import "../../../../components/input/ha-input";
|
||||
import { isCustomType } from "../../../../data/lovelace_custom_cards";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
@@ -90,6 +88,7 @@ const conditionalEntitiesRowConfigStruct = object({
|
||||
type: literal("conditional"),
|
||||
row: any(),
|
||||
conditions: array(any()),
|
||||
color: optional(string()),
|
||||
});
|
||||
|
||||
const dividerEntitiesRowConfigStruct = object({
|
||||
@@ -181,6 +180,22 @@ const entitiesRowConfigStruct = dynamic<any>((value) => {
|
||||
return entitiesConfigStruct;
|
||||
});
|
||||
|
||||
const SCHEMA = [
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{ name: "theme", selector: { theme: {} } },
|
||||
{
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
default_color: "state",
|
||||
include_state: true,
|
||||
include_none: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: "show_header_toggle", selector: { boolean: {} } },
|
||||
] as const;
|
||||
|
||||
const cardConfigStruct = assign(
|
||||
baseLovelaceCardConfig,
|
||||
object({
|
||||
@@ -189,7 +204,7 @@ const cardConfigStruct = assign(
|
||||
theme: optional(string()),
|
||||
icon: optional(string()),
|
||||
show_header_toggle: optional(boolean()),
|
||||
state_color: optional(boolean()),
|
||||
color: optional(string()),
|
||||
entities: array(entitiesRowConfigStruct),
|
||||
header: optional(headerFooterConfigStructs),
|
||||
footer: optional(headerFooterConfigStructs),
|
||||
@@ -226,14 +241,6 @@ export class HuiEntitiesCardEditor
|
||||
);
|
||||
});
|
||||
|
||||
get _title(): string {
|
||||
return this._config!.title || "";
|
||||
}
|
||||
|
||||
get _theme(): string {
|
||||
return this._config!.theme || "";
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
@@ -251,52 +258,20 @@ export class HuiEntitiesCardEditor
|
||||
`;
|
||||
}
|
||||
|
||||
const data = {
|
||||
...this._config,
|
||||
show_header_toggle: this._showHeaderToggle(this._config),
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="card-config">
|
||||
<ha-input
|
||||
.label="${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.title"
|
||||
)} (${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.card.config.optional"
|
||||
)})"
|
||||
.value=${this._title}
|
||||
.configValue=${"title"}
|
||||
@input=${this._valueChanged}
|
||||
></ha-input>
|
||||
<ha-theme-picker
|
||||
.value=${this._theme}
|
||||
.label=${`${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.theme"
|
||||
)} (${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.config.optional"
|
||||
)})`}
|
||||
.configValue=${"theme"}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-theme-picker>
|
||||
<div class="side-by-side">
|
||||
<ha-formfield
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.card.entities.show_header_toggle"
|
||||
)}
|
||||
>
|
||||
<ha-switch
|
||||
.checked=${this._showHeaderToggle(this._config)}
|
||||
.configValue=${"show_header_toggle"}
|
||||
@change=${this._valueChanged}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-formfield
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.state_color"
|
||||
)}
|
||||
>
|
||||
<ha-switch
|
||||
.checked=${this._config!.state_color}
|
||||
.configValue=${"state_color"}
|
||||
@change=${this._valueChanged}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
</div>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._formChanged}
|
||||
></ha-form>
|
||||
<hui-header-footer-editor
|
||||
.hass=${this.hass}
|
||||
.configValue=${"header"}
|
||||
@@ -321,6 +296,43 @@ export class HuiEntitiesCardEditor
|
||||
`;
|
||||
}
|
||||
|
||||
private _formChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = { ...ev.detail.value } as EntitiesCardConfig;
|
||||
if (
|
||||
this._config.show_header_toggle === undefined &&
|
||||
config.show_header_toggle === this._showHeaderToggle(this._config)
|
||||
) {
|
||||
delete config.show_header_toggle;
|
||||
}
|
||||
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
switch (schema.name) {
|
||||
case "title":
|
||||
case "theme":
|
||||
return `${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.${schema.name}`
|
||||
)} (${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.config.optional"
|
||||
)})`;
|
||||
case "show_header_toggle":
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.entities.show_header_toggle"
|
||||
);
|
||||
default:
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.${schema.name}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
if (!this._config || !this.hass) {
|
||||
@@ -330,17 +342,7 @@ export class HuiEntitiesCardEditor
|
||||
const target = ev.target! as EditorTarget;
|
||||
const configValue =
|
||||
target.configValue || this._subElementEditorConfig?.type;
|
||||
const value =
|
||||
target.checked !== undefined
|
||||
? target.checked
|
||||
: target.value || ev.detail.config || ev.detail.value;
|
||||
|
||||
if (
|
||||
(configValue === "title" && target.value === this._title) ||
|
||||
(configValue === "theme" && target.value === this._theme)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const value = ev.detail.config ?? ev.detail.value;
|
||||
|
||||
if (configValue === "row" || (ev.detail && ev.detail.entities)) {
|
||||
const newConfigEntities =
|
||||
@@ -359,7 +361,7 @@ export class HuiEntitiesCardEditor
|
||||
this._config = { ...this._config!, entities: newConfigEntities };
|
||||
this._configEntities = processEditorEntities(this._config!.entities);
|
||||
} else if (configValue) {
|
||||
if (value === "") {
|
||||
if (value === "" || value === undefined) {
|
||||
this._config = { ...this._config };
|
||||
delete this._config[configValue!];
|
||||
} else {
|
||||
@@ -435,10 +437,6 @@ export class HuiEntitiesCardEditor
|
||||
hui-header-footer-editor {
|
||||
padding-top: var(--ha-space-1);
|
||||
}
|
||||
|
||||
ha-input {
|
||||
--ha-input-padding-bottom: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
+16
-29
@@ -74,11 +74,7 @@ export abstract class HuiEntityModesCardFeatureEditorBase<
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
stateObj: HassEntity | undefined,
|
||||
customizeModes: boolean
|
||||
) => {
|
||||
(localize: LocalizeFunc, stateObj: HassEntity | undefined) => {
|
||||
const d = this.descriptor;
|
||||
const styleListId = d.styleListI18nFeatureId ?? d.i18nFeatureId;
|
||||
return [
|
||||
@@ -103,25 +99,20 @@ export abstract class HuiEntityModesCardFeatureEditorBase<
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
...(customizeModes
|
||||
? ([
|
||||
{
|
||||
name: d.modeField,
|
||||
selector: {
|
||||
select: {
|
||||
reorder: true,
|
||||
multiple: true,
|
||||
options: d
|
||||
.getAvailableModesOrdered(stateObj)
|
||||
.map((mode) => ({
|
||||
value: mode,
|
||||
label: d.getModeLabel(this.hass!, stateObj, mode),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: d.modeField,
|
||||
visible: { field: "customize_modes", value: true },
|
||||
selector: {
|
||||
select: {
|
||||
reorder: true,
|
||||
multiple: true,
|
||||
options: d.getAvailableModesOrdered(stateObj).map((mode) => ({
|
||||
value: mode,
|
||||
label: d.getModeLabel(this.hass!, stateObj, mode),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[];
|
||||
}
|
||||
);
|
||||
@@ -143,11 +134,7 @@ export abstract class HuiEntityModesCardFeatureEditorBase<
|
||||
customize_modes: this._config[modeField as keyof TConfig] !== undefined,
|
||||
};
|
||||
|
||||
const schema = this._schema(
|
||||
this.hass.localize,
|
||||
stateObj,
|
||||
data.customize_modes
|
||||
);
|
||||
const schema = this._schema(this.hass.localize, stateObj);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
@@ -80,7 +80,7 @@ export class HuiGaugeCardEditor
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(showSeverity: boolean, entityId?: string) =>
|
||||
(entityId?: string) =>
|
||||
[
|
||||
{
|
||||
name: "entity",
|
||||
@@ -132,28 +132,25 @@ export class HuiGaugeCardEditor
|
||||
{ name: "show_severity", selector: { boolean: {} } },
|
||||
],
|
||||
},
|
||||
...(showSeverity
|
||||
? ([
|
||||
{
|
||||
name: "severity",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "green",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "yellow",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "red",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const)
|
||||
: []),
|
||||
{
|
||||
name: "severity",
|
||||
type: "grid",
|
||||
visible: { field: "show_severity", value: true },
|
||||
schema: [
|
||||
{
|
||||
name: "green",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "yellow",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "red",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "interactions",
|
||||
type: "expandable",
|
||||
@@ -197,10 +194,7 @@ export class HuiGaugeCardEditor
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const schema = this._schema(
|
||||
this._config!.severity !== undefined,
|
||||
this._config!.entity
|
||||
);
|
||||
const schema = this._schema(this._config!.entity);
|
||||
const data = {
|
||||
show_severity: this._config!.severity !== undefined,
|
||||
...this._config,
|
||||
|
||||
@@ -59,13 +59,28 @@ export class HuiGenericEntityRowEditor
|
||||
context: { entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "icon",
|
||||
selector: {
|
||||
icon: {},
|
||||
},
|
||||
context: {
|
||||
icon_entity: "entity",
|
||||
},
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "icon",
|
||||
selector: {
|
||||
icon: {},
|
||||
},
|
||||
context: {
|
||||
icon_entity: "entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
include_state: true,
|
||||
include_none: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "secondary_info",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user