Compare commits

...

22 Commits

Author SHA1 Message Date
Paul Bottein 7d4d825b9a Add entity ID format setting for new entities 2026-07-23 15:47:49 +02:00
Paul Bottein 8775d3f4c5 Use ha-form conditional field visibility in dashboard editors (#53239)
* Use ha-form conditional field visibility in dashboard editors

* Keep view and section editors on localize context
2026-07-22 16:09:17 +01:00
Paul Bottein 2771a565b9 Rename ha-form conditional field to visible and skip it in config flow submission (#53168)
* Skip hidden fields in config flow submission

* Resolve hidden fields until stable so they hold no value

* Rename ha-form conditional field from `hidden` to `visible`
2026-07-22 16:06:03 +02:00
Petar Petrov d3505cece0 Fix missing app update button on app info page (#53238)
Fix missing app update button by resolving update entity reactively
2026-07-22 15:17:34 +02:00
Aidan Timson 8579f77c12 Add demo theme persistence tests (#53237)
* Add demo theme persistence tests

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Use shared demo theme storage key in e2e tests

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-22 15:50:08 +03:00
Aidan Timson 6249e037ed Add ask assist command action to quick search (#53234)
* Add ask assist action to quick search and support params in dialog

* Support native android

* Group Ask Assist with quick search commands

* Hide Ask Assist when native Assist is active

* Test Ask Assist from quick search

* Fix

* Use new test structure
2026-07-22 15:18:04 +03:00
Paul Bottein 8beb4ee36e Migrate entities and glance card state_color to color (#53151)
* Migrate entities and glance card state_color to color

* Improve tests
2026-07-22 15:08:10 +03:00
Maarten Lakerveld 58ded99773 Onboarding survey (#53236)
Use new OHF shortlink service
2026-07-22 12:26:54 +02:00
Timothy 0d2b99b3a5 Disable LaunchScreen for mobile (#53171)
* Disable LaunchScreen for mobile

* Apply suggestions
2026-07-22 12:36:41 +03:00
WazoAkaRapace 4be7a32148 Fix energy graph off-by-one colors with export-only grid source (#53145)
Fix energy usage graph off-by-one colors with export-only grid source

When a grid source configured without an import sensor (export-only) was
listed before other grid sources, the energy usage graph card assigned
color indices based on the filtered `statIds.from_grid` array — skipping
that source — instead of the positional index in the user's energy
sources config. This shifted every subsequent grid source's color by one
relative to the energy sources table card, which uses positional indices.

Populate `colorIndices.from_grid` and `colorIndices.to_grid` during the
source loop using a positional `gridIdx` counter, so import-only and
export-only grid sources no longer shift the colors of sources that
follow them.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-22 11:43:04 +03:00
Aidan Timson e47cebcf6e Workflows shared node modules cache (#53113)
* Benchmark shared dependency cache

* Avoid restoring warm producer cache

* Benchmark build with shared dependencies

* Use shared dependencies in CI

* Use one dependency cache key

* Allow manual CI runs

* Fall back when dependency cache is unavailable

* Keep dependency cache fallbacks fast
2026-07-22 10:16:08 +03:00
Petar Petrov e8bb029d7e Fix excessive y-axis decimals on near-flat energy charts (#53229) 2026-07-22 09:07:24 +02:00
Petar Petrov 4373f016c9 Fix energy usage graph stacking order for grid + solar without battery (#53230)
* Fix energy usage graph stacking so grid consumption stays above solar

* remove useless comment
2026-07-22 09:06:31 +02:00
renovate[bot] 9820428cd3 Update dependency @material/web to v2.5.0 (#53175)
* Update dependency @material/web to v2.5.0

* Update internal style imports for @material/web 2.5.0

2.5.0 renamed the generated internal `*-styles.js` modules to
`*-styles.cssresult.js` (they still export the named `styles`
CSSResult). Point our deep imports at the new paths.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-22 07:02:17 +00:00
karwosts 4fa09b0085 Don't present toggle as a ui_action option for untoggleable entities (#53210)
* Don't present toggle as a ui_action option for untoggleable entities

* Fix climate enums

* unmemoize

* fix test

* Add button/valve to canToggleDomain
2026-07-22 08:13:39 +03:00
Aidan Timson 5d63fad772 Migrate section and view editors to conditional visibility (#53217) 2026-07-22 08:11:08 +03:00
Aidan Timson 7f27c9a948 E2E performance improvements and consolidation (#53057)
* Run e2e suites in parallel

* Make suites run as parallel jobs in CI. Merge reports after

* Consolidate app route e2e helpers

* Move app e2e smoke data out of spec

* Tighten app e2e smoke timeouts

* Split e2e helpers by suite

* Consolidate app e2e smoke data

* Move app e2e helpers into src

* Enable workers and full paralelism to tests. Use 60% for local (4 in my case) and 1 for CI

* Shard e2e tests

* Fold e2e-local into report job

* Fix name

* Format

* Double sharding count

* Shard demo (2) and gallery (3)

* rm

* 6,4,6

* Add local sharded e2e runner

* Queue e2e benchmark runs

* Use test mode for e2e builds

* Fix e2e benchmark concurrency

* Build e2e app artifact modern only

* Build demo e2e artifact modern only

* 4/4/2 sharding

* Remove local sharded e2e runner

* Use Playwright image for e2e tests

* Remove redundant e2e test cases

* Run e2e sidebar checks concurrently

* Consolidate config link e2e checks

* Avoid duplicate gallery page waits

* Avoid slow gallery alert count wait

* Run app e2e readiness waits concurrently

* Avoid duplicate gallery readiness wait

* Restore concurrency

* Restore concurrency

* Share parallel e2e smoke registration

* Restore demo page error tracking

* Remove ineffective gallery test mode

* Remove unused route project filtering

* Remove duplicate smoke test parallel mode

* Keep page error filtering internal

* Require helper reuse in test changes

* Clarify local E2E server workflow

* Stabilize demo sidebar navigation test

* Avoid concurrent cold E2E builds

* Validate E2E worker overrides

* Restore real demo sidebar clicks

* Keep app shell error checks strict

* Limit gallery shell error filtering

* Address E2E review findings
2026-07-21 22:27:37 +02:00
Maarten Lakerveld c07264fc27 Add onboarding survey toast (#53221)
Conditions/triggers/actions use the same logic for the automations picker sorting
2026-07-21 21:54:39 +02:00
renovate[bot] 978ad69c1b Update dependency lint-staged to v17.1.0 (#53227)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-21 21:45:41 +02:00
Paul Bottein 047ebd4524 Sync entity constants with core (#53223)
* Sync entity constants with core

* Suggest mean statistics graphs for measurement_angle sensors
2026-07-21 21:45:12 +02:00
karwosts a25aa656a3 Replace feature magic numbers in media-player-model (#53214) 2026-07-21 10:04:39 +02:00
Simon Lamon 88f244b9df Replace the memoize call to fullcalendar (#53215)
Replace the memoize one
2026-07-21 10:04:13 +02:00
134 changed files with 4249 additions and 2085 deletions
+16 -8
View File
@@ -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.
+22 -1
View File
@@ -8,16 +8,37 @@ 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"
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: >-
node-modules-v1-${{ 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' || '' }}
+51 -4
View File
@@ -1,6 +1,7 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- dev
@@ -21,16 +22,56 @@ 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: 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: >-
node-modules-v1-${{ 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: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
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 +104,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 +124,7 @@ jobs:
build:
name: Build frontend
needs:
- prepare-dependencies
- lint
- test
runs-on: ubuntu-latest
@@ -88,8 +133,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:
+161 -40
View File
@@ -38,8 +38,9 @@ jobs:
- 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
@@ -65,8 +66,9 @@ jobs:
- 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
@@ -103,15 +105,27 @@ 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
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
@@ -121,60 +135,133 @@ jobs:
- name: Setup Node and install
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
- name: Download demo build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
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
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 and install
uses: ./.github/actions/setup
- 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
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 and install
uses: ./.github/actions/setup
- 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
@@ -187,12 +274,26 @@ jobs:
- name: Setup Node and install
uses: ./.github/actions/setup
- 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 +310,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 +322,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"
+4 -2
View File
@@ -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,
};
},
};
+16
View File
@@ -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(
+15
View File
@@ -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"
)
);
+10
View File
@@ -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(
+24
View File
@@ -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(),
})
)
);
+19 -4
View File
@@ -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
View File
@@ -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",
+16
View File
@@ -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"]
}
]
}
+6
View File
@@ -6,11 +6,17 @@ export const canToggleDomain = (hass: HomeAssistant, domain: string) => {
return false;
}
if (domain === "button" || domain === "input_button") {
return "press" in services;
}
if (domain === "lock") {
return "lock" in services;
}
if (domain === "cover") {
return "open_cover" in services;
}
if (domain === "valve") {
return "open_valve" in services;
}
return "turn_on" in services;
};
+5 -1
View File
@@ -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 { ClimateEntityFeature } from "../../data/climate";
export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
const domain = computeStateDomain(stateObj);
@@ -26,7 +27,10 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
}
if (domain === "climate") {
return supportsFeature(stateObj, 4096);
return (
supportsFeature(stateObj, ClimateEntityFeature.TURN_ON) &&
supportsFeature(stateObj, ClimateEntityFeature.TURN_OFF)
);
}
return canToggleDomain(hass, domain);
@@ -0,0 +1,15 @@
import type { EntityIdPart } from "../../data/entity_id_format";
import { slugify } from "../string/slugify";
export const computeEntityIdFormatExample = (
format: EntityIdPart[],
examples: Record<EntityIdPart, string>
): string => {
const parts = format
.map((item) => examples[item])
.filter(Boolean)
.map((part) => slugify(part, "_"))
.filter(Boolean);
return parts.join("_") || "unknown";
};
+7 -2
View File
@@ -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"],
+1 -1
View File
@@ -126,7 +126,7 @@ export class HaProgressButton extends LitElement {
visibility: hidden;
}
:host([appearance="brand"]) ha-svg-icon {
.progress ha-svg-icon {
color: var(--white-color);
}
`;
+23 -12
View File
@@ -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),
+3 -3
View File
@@ -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";
+5 -5
View File
@@ -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";
+5 -4
View File
@@ -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";
@@ -27,6 +27,7 @@ export class HaInputChip extends InputChip {
);
--ha-input-chip-selected-container-opacity: 1;
--md-input-chip-label-text-font: Roboto, sans-serif;
--md-input-chip-label-text-weight: 400;
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
@@ -33,7 +33,6 @@ const HIDDEN_ATTRIBUTES = [
"battery_level",
"code_arm_required",
"code_format",
"color_modes",
"device_class",
"editable",
"effect_list",
+24 -11
View File
@@ -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)})`;
+4 -2
View File
@@ -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}>
+37 -1
View File
@@ -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() {
+30 -7
View File
@@ -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;
};
+4 -2
View File
@@ -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 -3
View File
@@ -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;
}
+3 -3
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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";
+2 -2
View File
@@ -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";
+2 -2
View File
@@ -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";
+2 -2
View File
@@ -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 -1
View File
@@ -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";
+40 -14
View File
@@ -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}
+1 -1
View File
@@ -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
View File
@@ -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")
+2 -2
View File
@@ -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",
];
+34
View File
@@ -0,0 +1,34 @@
import type { HomeAssistantApi } from "../types";
export type EntityIdPart = "area" | "device" | "entity" | "floor";
export type EntityIdFormat = EntityIdPart[];
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
"area",
"device",
"entity",
];
export const isDefaultEntityIdFormat = (format: EntityIdFormat): boolean =>
JSON.stringify(format) === JSON.stringify(DEFAULT_ENTITY_ID_FORMAT);
export interface EntityRegistrySettings {
entity_id_parts: EntityIdFormat | null;
}
export const fetchEntityRegistrySettings = (
api: HomeAssistantApi
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/get",
});
export const updateEntityRegistrySettings = (
api: HomeAssistantApi,
updates: Partial<EntityRegistrySettings>
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/update",
...updates,
});
+8
View File
@@ -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 {
+2
View File
@@ -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":
+16 -6
View File
@@ -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);
+56 -17
View File
@@ -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,
},
});
};
+2 -1
View File
@@ -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 -1
View File
@@ -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);
+2 -1
View File
@@ -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" });
}
}
}
+5 -1
View File
@@ -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",
+9
View File
@@ -22,6 +22,7 @@ import {
mdiPuzzle,
mdiRadioTower,
mdiRemote,
mdiRenameOutline,
mdiRobot,
mdiScrewdriver,
mdiScriptText,
@@ -494,6 +495,14 @@ export const configSections: Record<string, PageNavigation[]> = {
component: "backup",
adminOnly: true,
},
{
path: "/config/naming",
translationKey: "naming",
iconPath: mdiRenameOutline,
iconColor: "#24a5cb",
core: true,
adminOnly: true,
},
{
path: "/config/analytics",
translationKey: "analytics",
@@ -0,0 +1,49 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "./ha-entity-id-format-card";
@customElement("ha-config-section-naming")
export class HaConfigSectionNaming extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected render() {
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.naming.caption")}
>
<div class="content">
<ha-entity-id-format-card></ha-entity-id-format-card>
</div>
</hass-subpage>
`;
}
static styles = css`
.content {
padding: var(--ha-space-7) var(--ha-space-5) 0;
max-width: 1040px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
}
ha-entity-id-format-card {
max-width: 600px;
margin: 0 auto;
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-section-naming": HaConfigSectionNaming;
}
}
@@ -0,0 +1,227 @@
import { consume, type ContextType } from "@lit/context";
import { mdiRestore } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { computeEntityIdFormatExample } from "../../../common/entity/compute_entity_id_format_example";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-svg-icon";
import { apiContext, configContext } from "../../../data/context";
import {
DEFAULT_ENTITY_ID_FORMAT,
fetchEntityRegistrySettings,
isDefaultEntityIdFormat,
updateEntityRegistrySettings,
type EntityIdFormat,
type EntityIdPart,
} from "../../../data/entity_id_format";
import { haStyle } from "../../../resources/styles";
import { documentationUrl } from "../../../util/documentation-url";
import "./ha-entity-id-format-editor";
const EXAMPLE_DOMAIN = "sensor";
@customElement("ha-entity-id-format-card")
export class HaEntityIdFormatCard extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state() private _format?: EntityIdFormat;
@state() private _error?: string;
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
try {
const settings = await fetchEntityRegistrySettings(this._api);
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
} catch (err: any) {
this._error = err.message;
}
}
private _examples = memoizeOne(
(localize: LocalizeFunc): Record<EntityIdPart, string> => ({
area: localize(
"ui.panel.config.naming.entity_id_format.editor.examples.area"
),
device: localize(
"ui.panel.config.naming.entity_id_format.editor.examples.device"
),
entity: localize(
"ui.panel.config.naming.entity_id_format.editor.examples.entity"
),
floor: localize(
"ui.panel.config.naming.entity_id_format.editor.examples.floor"
),
})
);
protected render() {
return html`
<ha-card
outlined
.header=${this._localize(
"ui.panel.config.naming.entity_id_format.header"
)}
>
<div class="card-content">
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<p class="description">
${this._localize(
"ui.panel.config.naming.entity_id_format.description"
)}
<a
href=${documentationUrl(
this._config,
"/docs/configuration/customizing-devices/"
)}
target="_blank"
rel="noreferrer"
>${this._localize(
"ui.panel.config.naming.entity_id_format.learn_more"
)}</a
>
</p>
${
this._format
? html`
<ha-entity-id-format-editor
.value=${this._format}
.label=${this._localize(
"ui.panel.config.naming.entity_id_format.editor.label"
)}
@value-changed=${this._formatChanged}
></ha-entity-id-format-editor>
${this._renderPreview()}
`
: nothing
}
</div>
<div class="card-actions">
<ha-button
appearance="plain"
@click=${this._reset}
.disabled=${!this._format || isDefaultEntityIdFormat(this._format)}
>
<ha-svg-icon slot="start" .path=${mdiRestore}></ha-svg-icon>
${this._localize("ui.panel.config.naming.entity_id_format.reset")}
</ha-button>
<ha-progress-button
appearance="filled"
@click=${this._save}
.disabled=${!this._format}
>
${this._localize("ui.common.save")}
</ha-progress-button>
</div>
</ha-card>
`;
}
private _renderPreview() {
const example = computeEntityIdFormatExample(
this._format!,
this._examples(this._localize)
);
return html`
<div class="preview">
<span class="preview-label">
${this._localize("ui.panel.config.naming.entity_id_format.preview")}
</span>
<code>${EXAMPLE_DOMAIN}.${example}</code>
</div>
`;
}
private _formatChanged(ev: CustomEvent) {
this._format = ev.detail.value;
}
private _reset() {
this._format = [...DEFAULT_ENTITY_ID_FORMAT];
}
private async _save(ev: Event) {
const button = ev.currentTarget as HaProgressButton;
if (button.progress) {
return;
}
button.progress = true;
this._error = undefined;
try {
const format = this._format!;
const settings = await updateEntityRegistrySettings(this._api, {
entity_id_parts: isDefaultEntityIdFormat(format) ? null : format,
});
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
button.actionSuccess();
} catch (err: any) {
button.actionError();
this._error = err.message;
} finally {
button.progress = false;
}
}
static styles = [
haStyle,
css`
:host {
display: block;
}
.description {
margin-top: 0;
color: var(--secondary-text-color);
}
.preview {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
margin-top: var(--ha-space-5);
}
.preview-label {
font-weight: 500;
}
.preview code {
display: block;
padding: var(--ha-space-3);
border-radius: var(--ha-border-radius-md);
background-color: var(--secondary-background-color);
font-family: var(--ha-font-family-code);
overflow-wrap: anywhere;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-id-format-card": HaEntityIdFormatCard;
}
}
@@ -0,0 +1,348 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/chips/ha-assist-chip";
import "../../../components/chips/ha-chip-set";
import "../../../components/chips/ha-input-chip";
import "../../../components/ha-combo-box-item";
import "../../../components/ha-generic-picker";
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type {
EntityIdFormat,
EntityIdPart,
} from "../../../data/entity_id_format";
import type { ValueChangedEvent } from "../../../types";
const STRUCTURAL_TYPES = ["area", "device", "entity", "floor"] as const;
const REQUIRED_TYPES: readonly EntityIdPart[] = ["device", "entity"];
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact>
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
const encodeType = (type: EntityIdPart) => `___${type}___`;
const decodeType = (value: string): EntityIdPart | undefined => {
const type =
value.startsWith("___") && value.endsWith("___")
? value.slice(3, -3)
: value;
return (STRUCTURAL_TYPES as readonly string[]).includes(type)
? (type as EntityIdPart)
: undefined;
};
@customElement("ha-entity-id-format-editor")
export class HaEntityIdFormatEditor extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public value: EntityIdFormat = [];
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _editIndex?: number;
protected render() {
return html`
<div class="container">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.disabled=${this.disabled}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
@value-changed=${this._pickerValueChanged}
.searchLabel=${this._localize(
"ui.panel.config.naming.entity_id_format.editor.search"
)}
>
<div slot="field" class="field">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this.value,
(item) => item,
(item: EntityIdPart, idx) => {
const label = this._formatType(item);
if (REQUIRED_TYPES.includes(item)) {
return html`
<ha-assist-chip
filled
class="required"
.label=${label}
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-assist-chip>
`;
}
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._localize(
"ui.panel.config.naming.entity_id_format.editor.add"
)}
class="add"
>
<ha-svg-icon
slot="icon"
.path=${mdiPlus}
></ha-svg-icon>
</ha-assist-chip>
`
}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
</div>
`;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
}
private _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newValue = this.value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this.value];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const type = decodeType(value);
if (!type) {
return;
}
const newValue = [...this.value];
if (this._editIndex != null) {
newValue[this._editIndex] = type;
this._editIndex = undefined;
} else {
newValue.push(type);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityIdFormat) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
private _formatType = (type: EntityIdPart) =>
this._localize(`ui.components.entity.entity-name-picker.types.${type}`);
private _getItems = memoizeOne(
(localize: LocalizeFunc): PickerComboBoxItem[] =>
STRUCTURAL_TYPES.map((type) => {
const primary = localize(
`ui.components.entity.entity-name-picker.types.${type}`
);
const id = encodeType(type);
return {
id,
primary,
search_labels: { primary, id },
sorting_label: primary,
};
})
);
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
const item = this.value[this._editIndex];
return item ? encodeType(item) : undefined;
}
return undefined;
}
private _getFilteredItems = (): PickerComboBoxItem[] => {
const items = this._getItems(this._localize);
const currentValue =
this._editIndex != null
? encodeType(this.value[this._editIndex])
: undefined;
const usedValues = new Set(this.value.map((item) => encodeType(item)));
return items.filter(
(item) => !usedValues.has(item.id) || item.id === currentValue
);
};
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-weight: 500;
}
ha-generic-picker {
width: 100%;
}
.field {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.field:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
}
:host([disabled]) .field:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.field:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
ha-chip-set {
padding: var(--ha-space-3);
}
ha-assist-chip.required {
--ha-assist-chip-filled-container-color: rgba(
var(--rgb-primary-text-color),
0.15
);
}
.add {
order: 1;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-id-format-editor": HaEntityIdFormatEditor;
}
}
+4
View File
@@ -170,6 +170,10 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-section-ai-tasks",
load: () => import("./core/ha-config-section-ai-tasks"),
},
naming: {
tag: "ha-config-section-naming",
load: () => import("./core/ha-config-section-naming"),
},
zha: {
tag: "zha-config-dashboard-router",
load: () =>
@@ -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
+14 -13
View File
@@ -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;
}
+12 -8
View File
@@ -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>
`
: ""
@@ -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)[],
};
};
+8
View File
@@ -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;
};
@@ -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`
@@ -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);
}
`,
];
}
@@ -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",
@@ -44,7 +44,7 @@ const cardConfigStruct = assign(
show_name: optional(boolean()),
show_state: optional(boolean()),
show_icon: optional(boolean()),
state_color: optional(boolean()),
color: optional(string()),
entities: array(entitiesConfigStruct),
})
);
@@ -69,7 +69,16 @@ const SCHEMA = [
{ name: "show_state", selector: { boolean: {} } },
],
},
{ name: "state_color", selector: { boolean: {} } },
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
include_none: true,
},
},
},
] as const;
@customElement("hui-glance-card-editor")
@@ -115,6 +124,15 @@ export class HuiGlanceCardEditor
icon_entity: "entity",
},
},
{
name: "color",
selector: {
ui_color: {
include_state: true,
include_none: true,
},
},
},
{ name: "show_last_changed", selector: { boolean: {} } },
{ name: "show_state", selector: { boolean: {} }, default: true },
],
@@ -102,11 +102,11 @@ const SCHEMA = [
{
name: "fit_y_data",
required: false,
hidden: {
condition: "and",
visible: {
condition: "or",
conditions: [
{ field: "min_y_axis", operator: "not_exists" },
{ field: "max_y_axis", operator: "not_exists" },
{ field: "min_y_axis", operator: "exists" },
{ field: "max_y_axis", operator: "exists" },
],
},
selector: { boolean: {} },
@@ -34,20 +34,18 @@ export class HuiLawnMowerCommandsCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne(
(stateObj: HassEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "commands",
customize,
options: LAWN_MOWER_COMMANDS.filter(
(command) => stateObj && supportsLawnMowerCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.commands_list.${command}`
),
})),
})
private _schema = memoizeOne((stateObj: HassEntity | undefined) =>
customizableListSchema({
field: "commands",
options: LAWN_MOWER_COMMANDS.filter(
(command) => stateObj && supportsLawnMowerCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.commands_list.${command}`
),
})),
})
);
protected render() {
@@ -60,7 +58,7 @@ export class HuiLawnMowerCommandsCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "commands");
const schema = this._schema(stateObj, data.customize);
const schema = this._schema(stateObj);
return html`
<ha-form
@@ -51,7 +51,7 @@ export class HuiMarkdownCardEditor
}
private _schema = memoizeOne(
(localize: LocalizeFunc, text_only: boolean) =>
(localize: LocalizeFunc) =>
[
{
name: "style",
@@ -73,9 +73,11 @@ export class HuiMarkdownCardEditor
},
},
},
...(!text_only
? ([{ name: "title", selector: { text: {} } }] as const)
: []),
{
name: "title",
visible: { field: "style", operator: "not_eq", value: "text-only" },
selector: { text: {} },
},
{
name: "content",
required: true,
@@ -131,10 +133,7 @@ export class HuiMarkdownCardEditor
style: this._config.text_only ? "text-only" : "card",
};
const schema = this._schema(
this.hass.localize,
this._config.text_only || false
);
const schema = this._schema(this.hass.localize);
return html`
<ha-form
@@ -38,11 +38,10 @@ export class HuiMediaPlayerPlaybackCardFeatureEditor
}
private _schema = memoizeOne(
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
(stateObj: MediaPlayerEntity | undefined) =>
[
...customizableListSchema({
field: "controls",
customize,
options: MEDIA_PLAYER_PLAYBACK_CONTROLS.filter(
(control) =>
stateObj && supportsMediaPlayerPlaybackControl(stateObj, control)
@@ -69,7 +68,7 @@ export class HuiMediaPlayerPlaybackCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "controls");
const schema = this._schema(stateObj, data.customize);
const schema = this._schema(stateObj);
return html`
<ha-form
@@ -32,21 +32,19 @@ export class HuiMediaPlayerSoundModeCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne(
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "sound_modes",
customize,
options:
stateObj?.attributes.sound_mode_list?.map((mode) => ({
value: mode,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"sound_mode",
mode
),
})) ?? [],
})
private _schema = memoizeOne((stateObj: MediaPlayerEntity | undefined) =>
customizableListSchema({
field: "sound_modes",
options:
stateObj?.attributes.sound_mode_list?.map((mode) => ({
value: mode,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"sound_mode",
mode
),
})) ?? [],
})
);
protected render() {
@@ -60,7 +58,7 @@ export class HuiMediaPlayerSoundModeCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "sound_modes");
const schema = this._schema(stateObj, data.customize);
const schema = this._schema(stateObj);
return html`
<ha-form
@@ -32,21 +32,19 @@ export class HuiMediaPlayerSourceCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne(
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "sources",
customize,
options:
stateObj?.attributes.source_list?.map((source) => ({
value: source,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"source",
source
),
})) ?? [],
})
private _schema = memoizeOne((stateObj: MediaPlayerEntity | undefined) =>
customizableListSchema({
field: "sources",
options:
stateObj?.attributes.source_list?.map((source) => ({
value: source,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"source",
source
),
})) ?? [],
})
);
protected render() {
@@ -60,7 +58,7 @@ export class HuiMediaPlayerSourceCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "sources");
const schema = this._schema(stateObj, data.customize);
const schema = this._schema(stateObj);
return html`
<ha-form
@@ -38,15 +38,10 @@ export class HuiPrecipitationForecastCardFeatureEditor
}
private _schema = memoizeOne(
(
stateObj: HassEntity | undefined,
forecastType: ForecastResolution,
localize: HomeAssistant["localize"]
) => {
(stateObj: HassEntity | undefined, localize: HomeAssistant["localize"]) => {
const supportedTypes = stateObj
? getSupportedForecastTypes(stateObj)
: [];
const isHourly = forecastType === "hourly";
return [
{
name: "forecast_type",
@@ -67,17 +62,22 @@ export class HuiPrecipitationForecastCardFeatureEditor
},
},
},
isHourly
? {
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
}
: {
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
visible: { field: "forecast_type", value: "hourly" },
selector: { number: { min: 1, mode: "box" } },
},
{
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
visible: {
field: "forecast_type",
operator: "not_eq",
value: "hourly",
},
selector: { number: { min: 1, mode: "box" } },
},
{
name: "precipitation_type",
required: true,
@@ -141,7 +141,7 @@ export class HuiPrecipitationForecastCardFeatureEditor
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
};
const schema = this._schema(stateObj, resolvedType, this.hass.localize);
const schema = this._schema(stateObj, this.hass.localize);
return html`
<ha-form
@@ -38,8 +38,7 @@ export class HuiSelectOptionsCardFeatureEditor
private _schema = memoizeOne(
(
formatEntityState: FormatEntityStateFunc,
stateObj: HassEntity | undefined,
customizeOptions: boolean
stateObj: HassEntity | undefined
) =>
[
{
@@ -48,24 +47,21 @@ export class HuiSelectOptionsCardFeatureEditor
boolean: {},
},
},
...(customizeOptions
? ([
{
name: "options",
selector: {
select: {
multiple: true,
reorder: true,
options:
stateObj?.attributes.options?.map((option) => ({
value: option,
label: formatEntityState(stateObj, option),
})) || [],
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
{
name: "options",
visible: { field: "customize_options", value: true },
selector: {
select: {
multiple: true,
reorder: true,
options:
stateObj?.attributes.options?.map((option) => ({
value: option,
label: formatEntityState(stateObj, option),
})) || [],
},
},
},
] as const satisfies readonly HaFormSchema[]
);
@@ -83,11 +79,7 @@ export class HuiSelectOptionsCardFeatureEditor
customize_options: this._config.options !== undefined,
};
const schema = this._schema(
this.hass.formatEntityState,
stateObj,
data.customize_options
);
const schema = this._schema(this.hass.formatEntityState, stateObj);
return html`
<ha-form
@@ -157,8 +157,6 @@ export class HuiStatisticsGraphCardEditor
localize: LocalizeFunc,
statisticIds: string[] | undefined,
metaDatas: StatisticsMetaData[] | undefined,
showFitOption: boolean,
hiddenLegend: boolean,
enableDateSelect: boolean
) => {
const units = new Set<string>();
@@ -291,15 +289,18 @@ export class HuiStatisticsGraphCardEditor
required: false,
selector: { number: { mode: "box", step: "any" } },
},
...(showFitOption
? [
{
name: "fit_y_data",
required: false,
selector: { boolean: {} },
},
]
: []),
{
name: "fit_y_data",
required: false,
visible: {
condition: "or",
conditions: [
{ field: "min_y_axis", operator: "exists" },
{ field: "max_y_axis", operator: "exists" },
],
},
selector: { boolean: {} },
},
{
name: "logarithmic_scale",
required: false,
@@ -310,15 +311,16 @@ export class HuiStatisticsGraphCardEditor
required: false,
selector: { boolean: {} },
},
...(!hiddenLegend
? [
{
name: "expand_legend",
required: false,
selector: { boolean: {} },
},
]
: []),
{
name: "expand_legend",
required: false,
visible: {
field: "hide_legend",
operator: "not_eq",
value: true,
},
selector: { boolean: {} },
},
],
},
],
@@ -397,9 +399,6 @@ export class HuiStatisticsGraphCardEditor
this.hass.localize,
this._configEntities,
this._metaDatas,
this._config!.min_y_axis !== undefined ||
this._config!.max_y_axis !== undefined,
!!this._config!.hide_legend,
!!this._config!.energy_date_selection
);
const configured_stat_types = this._config!.stat_types
@@ -38,15 +38,10 @@ export class HuiTemperatureForecastCardFeatureEditor
}
private _schema = memoizeOne(
(
stateObj: HassEntity | undefined,
forecastType: ForecastResolution,
localize: HomeAssistant["localize"]
) => {
(stateObj: HassEntity | undefined, localize: HomeAssistant["localize"]) => {
const supportedTypes = stateObj
? getSupportedForecastTypes(stateObj)
: [];
const isHourly = forecastType === "hourly";
return [
{
name: "forecast_type",
@@ -67,17 +62,22 @@ export class HuiTemperatureForecastCardFeatureEditor
},
},
},
isHourly
? {
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
}
: {
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
visible: { field: "forecast_type", value: "hourly" },
selector: { number: { min: 1, mode: "box" } },
},
{
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
visible: {
field: "forecast_type",
operator: "not_eq",
value: "hourly",
},
selector: { number: { min: 1, mode: "box" } },
},
{
name: "color",
selector: {
@@ -117,7 +117,7 @@ export class HuiTemperatureForecastCardFeatureEditor
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
};
const schema = this._schema(stateObj, resolvedType, this.hass.localize);
const schema = this._schema(stateObj, this.hass.localize);
return html`
<ha-form
@@ -145,7 +145,7 @@ export class HuiTileCardEditor
},
{
name: "state_content",
hidden: { field: "hide_state", value: true },
visible: { field: "hide_state", operator: "not_eq", value: true },
selector: {
ui_state_content: {
allow_context: true,
@@ -157,7 +157,7 @@ export class HuiTileCardEditor
},
{
name: "time_format",
hidden: !showTimeFormat,
visible: showTimeFormat,
selector: {
ui_time_format: {},
},
@@ -37,20 +37,18 @@ export class HuiVacuumCommandsCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne(
(stateObj: HassEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "commands",
customize,
options: VACUUM_COMMANDS.filter(
(command) => stateObj && supportsVacuumCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.vacuum-commands.commands_list.${command}`
),
})),
})
private _schema = memoizeOne((stateObj: HassEntity | undefined) =>
customizableListSchema({
field: "commands",
options: VACUUM_COMMANDS.filter(
(command) => stateObj && supportsVacuumCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.vacuum-commands.commands_list.${command}`
),
})),
})
);
protected render() {
@@ -63,7 +61,7 @@ export class HuiVacuumCommandsCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "commands");
const schema = this._schema(stateObj, data.customize);
const schema = this._schema(stateObj);
return html`
<ha-form
@@ -137,7 +137,6 @@ export class HuiDialogEditSection
case "tab-settings":
content = html`
<hui-section-settings-editor
.hass=${this.hass}
.config=${this._config}
.viewConfig=${this._viewConfig}
@value-changed=${this._configChanged}
@@ -2,6 +2,7 @@ import { mdiPalette } from "@mdi/js";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
@@ -15,7 +16,6 @@ import {
type LovelaceSectionRawConfig,
} from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
interface SettingsData {
column_span?: number;
@@ -27,14 +27,15 @@ interface SettingsData {
@customElement("hui-section-settings-editor")
export class HuiDialogEditSection extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public config!: LovelaceSectionRawConfig;
@property({ attribute: false }) public viewConfig!: LovelaceViewConfig;
private _schema = memoizeOne(
(maxColumns: number, backgroundEnabled: boolean, localize: LocalizeFunc) =>
(maxColumns: number, localize: LocalizeFunc) =>
[
{
name: "column_span",
@@ -50,48 +51,45 @@ export class HuiDialogEditSection extends LitElement {
name: "background_enabled",
selector: { boolean: {} },
},
...(backgroundEnabled
? ([
{
name: "background",
type: "expandable",
flatten: true,
expanded: true,
iconPath: mdiPalette,
schema: [
{
name: "background_color",
selector: {
ui_color: {
extra_options: [
{
value: "default",
label: localize(
"ui.panel.lovelace.editor.edit_section.settings.background_color_default"
),
display_color:
"var(--ha-section-background-color, var(--secondary-background-color))",
},
],
},
{
name: "background",
type: "expandable",
flatten: true,
expanded: true,
visible: { field: "background_enabled", value: true },
iconPath: mdiPalette,
schema: [
{
name: "background_color",
selector: {
ui_color: {
extra_options: [
{
value: "default",
label: localize(
"ui.panel.lovelace.editor.edit_section.settings.background_color_default"
),
display_color:
"var(--ha-section-background-color, var(--secondary-background-color))",
},
},
{
name: "background_opacity",
selector: {
number: {
min: 0,
max: 100,
step: 1,
unit_of_measurement: "%",
mode: "slider",
},
},
},
],
],
},
},
] as const satisfies readonly HaFormSchema[])
: []),
},
{
name: "background_opacity",
selector: {
number: {
min: 0,
max: 100,
step: 1,
unit_of_measurement: "%",
mode: "slider",
},
},
},
],
},
{
name: "theme",
selector: {
@@ -116,13 +114,11 @@ export class HuiDialogEditSection extends LitElement {
const schema = this._schema(
this.viewConfig.max_columns || 4,
backgroundEnabled,
this.hass.localize
this._localize
);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabel}
@@ -135,14 +131,14 @@ export class HuiDialogEditSection extends LitElement {
private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass.localize(
this._localize(
`ui.panel.lovelace.editor.edit_section.settings.${schema.name}`
);
private _computeHelper = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass.localize(
this._localize(
`ui.panel.lovelace.editor.edit_section.settings.${schema.name}_helper`
) || "";
@@ -14,7 +14,7 @@ export const entitiesConfigStruct = union([
image: optional(string()),
secondary_info: optional(string()),
time_format: optional(timeFormatConfigStruct),
state_color: optional(boolean()),
color: optional(string()),
tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct),
double_tap_action: optional(actionConfigStruct),
@@ -170,7 +170,6 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
content = html`
<hui-view-editor
.isNew=${this._params.viewIndex === undefined}
.hass=${this.hass}
.config=${this._config}
@view-config-changed=${this._viewConfigChanged}
></hui-view-editor>

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