Compare commits

...

29 Commits

Author SHA1 Message Date
Bram Kragten 418327d6fe Fix js-yaml v5 default import in gallery build script
js-yaml v5 removed the ESM default export. The gallery page gatherer
imported it as a default, which crashed the gulp resource build and
cascaded into the lint, test, and all build CI jobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:49:06 +02:00
Bram Kragten e637c0c583 Update dependency js-yaml to v5
js-yaml v5 is a major rewrite with breaking API and behavior changes.
This migrates our usage so behavior matches the YAML 1.1 semantics of
the PyYAML backend (the previous v4 default).

- Replace removed `DEFAULT_SCHEMA` with `YAML11_SCHEMA`. v5's new load
  default is `CORE_SCHEMA` (YAML 1.2), which parses `on`/`off`/`yes`/`no`
  as strings and drops merge keys (`<<`). `YAML11_SCHEMA` preserves the
  boolean semantics and `!!merge` we (and the backend) rely on.
- Pin the bare `load(paste)` calls in the automation and script editors
  to `YAML11_SCHEMA` for the same reason.
- Port the custom `!secret` tag from the removed `Type`/`Schema.extend()`
  API to `defineScalarTag()` + `Schema.withTags()`.
- Drop the removed `dump()` option `quotingType` (the v5 default
  `quoteStyle: "auto"` quotes only when needed and round-trips safely).
- Remove `@types/js-yaml`; v5 ships its own TypeScript types.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:33:36 +02:00
Bram Kragten 77cef2429b Fix minify-literals build error in box-shadow gallery page (#52840)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:08:38 +00:00
Jan-Philipp Benecke b6eb4a50d9 Fix ES5 transpilation for lit-html (#52835)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-06-24 11:59:06 +00:00
Aidan Timson 75fded1a43 Migrate entity picker to context (#52833) 2026-06-24 13:53:48 +02:00
Bram Kragten e53ffd76ac Add Playwright e2e tests (local Chromium) (#51929)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:50:47 +02:00
Petar Petrov 54c54fa5a2 Fix media player volume slider clipped at 100% in entities card (#52838) 2026-06-24 12:48:05 +01:00
Petar Petrov a4aec3a734 Replace babel-plugin-template-html-minifier with minify-literals (#52818) 2026-06-24 12:12:57 +02:00
Bram Kragten c73e735164 Fix search bar look in datatables (#52831)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:11:32 +00:00
Petar Petrov d4b1fe0c7f Roll the energy "Now" view over to the new day at midnight (#52829) 2026-06-24 10:08:41 +02:00
Dmytro Platov 8ecd350e6f Add Zigbee configuration handling and loading state to ZHA dashboard (#52697)
* Add Zigbee configuration handling and loading state to ZHA dashboard

- Introduced `findActiveZhaConfigEntry` function to filter active Zigbee config entries.
- Updated ZHAConfigDashboard to manage loading state and display a spinner while loading.
- Added UI elements for not configured state with appropriate translations.
- Created tests for `findActiveZhaConfigEntry` to ensure correct functionality.

* fix: remove unused config entry logic and update initialization checks

* Restore active config entry filter in _fetchConfigEntry

* Remove redundant config entry ternary in render
2026-06-24 06:48:18 +00:00
renovate[bot] a26de31a2d Update dependency lint-staged to v17.0.8 (#52825)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-24 07:33:20 +03:00
renovate[bot] 77110afc59 Update Node.js to v24.18.0 (#52827)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-24 07:32:55 +03:00
Paul Bottein 7b6e9ba738 Add by entity suggestions to the badge picker (#52733) 2026-06-23 21:07:14 +02:00
renovate[bot] 1b15bc721b Update babel monorepo (#52814)
* Update babel monorepo

* Migrate Core-JS polyfilling for Babel 8

Babel 8.0.1 removed preset-env's `useBuiltIns`/`corejs` options. Replace
them with the babel-plugin-polyfill-corejs3 provider directly
(`usage-global`), and pin transform-runtime's `moduleName` to
`@babel/runtime` so the provider doesn't redirect helpers to the
uninstalled `@babel/runtime-corejs3`.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-23 15:38:07 +03:00
Bram Kragten 93a8d296a8 Move purpose-specific triggers and conditions out of labs (#52801) 2026-06-23 14:11:32 +02:00
Paul Bottein 734fed21a8 Add name detail option to activity card (#52815)
* Add name detail option to activity card

* Fix tests

* Review
2026-06-23 13:33:36 +02:00
Aidan Timson af203d640f Use helpers for related context (#52816) 2026-06-23 10:08:50 +00:00
Petar Petrov da29c8f536 Remove dead and unused hass props/bindings from migrated leaves (#52805) 2026-06-23 09:07:02 +01:00
Petar Petrov 8069596c87 Migrate registry display editors to context instead of hass (#52804) 2026-06-23 09:06:15 +01:00
Petar Petrov ace55fdb92 Migrate ha-qr-scanner to context instead of hass (#52806) 2026-06-23 09:04:51 +01:00
renovate[bot] dae8adab98 Update babel monorepo to v8 (#52758)
* Update babel monorepo to v8

* Bugfixes option has been removed and is enabled by default

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-23 09:56:55 +03:00
Paul Bottein 1522d979de Fix multi-term picker search ranking (#52807) 2026-06-23 08:15:37 +03:00
renovate[bot] 5fd253b2d3 Update dependency @types/luxon to v3.7.2 (#52812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-23 08:12:52 +03:00
Paulus Schoutsen f7d5195161 Fix inverted managed check for cloud webhook disable (#52813)
The manage-cloudhook dialog showed "managed by an integration and cannot
be disabled" for webhooks the user enabled manually (e.g. an automation
webhook trigger), while letting integration-managed webhooks (e.g. the
mobile app) be disabled.

Core sets `managed: true` only for cloudhooks created programmatically by
an integration (via async_create_cloudhook) and `managed: false` for hooks
the user creates through the cloud panel. The dialog negated this flag, so
the message and disable link were shown for the wrong cases. Check the flag
directly so user-created hooks expose the disable link and integration
hooks show the informational message.


Claude-Session: https://claude.ai/code/session_01QuvU786Re5Rm1iCa8BhT8d

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-23 08:12:32 +03:00
karwosts b72791a9e2 time_format migration and enhancements in entities and glance cards (#52768)
* time_format migration and enhancements in entities and glance cards

* migration updates
2026-06-22 17:24:40 +02:00
Aidan Timson a3c0e8d519 Use related context in entity picker, send context on edit card/badge (#52798)
* Use related context in entity picker

* Include current item in context builder

* Fix

* Add tests

* Remove comment

* Support area context in card

* Add from rebase

* Add window.haContext.related to tests
2026-06-22 17:43:38 +03:00
Clément Notin 19fcb9d2f7 Allow to open tabs in Developer tools to new tabs (middle-click, CTRL+Click...) (#52785)
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2026-06-22 14:36:49 +00:00
AlCalzone cbd90884ee Set ha-progress-ring-size before rendering (#52794)
Set ha-progress-ring-size before rendering
2026-06-22 15:33:00 +02:00
185 changed files with 6312 additions and 1902 deletions
+244
View File
@@ -0,0 +1,244 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
workflow_dispatch:
env:
NODE_OPTIONS: --max_old_space_size=6144
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
if-no-files-found: error
retention-days: 3
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
if-no-files-found: error
retention-days: 3
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
# Chromium; anything longer is almost certainly an install or webServer
# hang.
timeout-minutes: 30
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
# Cache the downloaded browser build keyed on the pinned Playwright
# version (yarn.lock), so re-runs skip the ~170 MB download.
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
- name: Install Playwright browsers
run: yarn playwright install --with-deps chromium
timeout-minutes: 10
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
timeout-minutes: 15
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
retention-days: 3
# ── Merge local blob reports and post PR comment ───────────────────────────
report:
name: Report
needs: [e2e-local]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
- name: Merge blob reports
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E tests failed\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
+8
View File
@@ -54,8 +54,16 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# Playwright e2e output
test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# AI tooling
.claude
.cursor
.opencode
.serena
test/benchmarks/results/
+1 -1
View File
@@ -1 +1 @@
24.17.0
24.18.0
+36 -34
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -84,12 +83,7 @@ module.exports.swcOptions = () => ({
},
});
module.exports.babelOptions = ({
latestBuild,
isProdBuild,
isTestBuild,
sw,
}) => ({
module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
babelrc: false,
compact: false,
assumptions: {
@@ -102,14 +96,22 @@ module.exports.babelOptions = ({
[
"@babel/preset-env",
{
useBuiltIns: "usage",
corejs: dependencies["core-js"],
bugfixes: true,
shippedProposals: true,
},
],
],
plugins: [
// Inject Core-JS polyfills on demand. Babel 8 removed preset-env's
// `useBuiltIns`/`corejs` options, so the equivalent polyfill provider is
// configured directly here (`usage-global` matches the old `useBuiltIns: "usage"`).
[
"babel-plugin-polyfill-corejs3",
{
method: "usage-global",
version: dependencies["core-js"],
shippedProposals: true,
},
],
[
path.join(BABEL_PLUGINS, "inline-constants-plugin.cjs"),
{
@@ -117,32 +119,14 @@ module.exports.babelOptions = ({
ignoreModuleNotFound: true,
},
],
// Minify template literals for production
isProdBuild && [
"template-html-minifier",
{
modules: {
...Object.fromEntries(
["lit", "lit-element", "lit-html"].map((m) => [
m,
[
"html",
{ name: "svg", encapsulation: "svg" },
{ name: "css", encapsulation: "style" },
],
])
),
"@polymer/polymer/lib/utils/html-tag.js": ["html"],
},
strictCSS: true,
htmlMinifier: module.exports.htmlMinifierOptions,
failOnError: false, // we can turn this off in case of false positives
},
],
// Import helpers and regenerator from runtime package
// Import helpers and regenerator from runtime package.
// `moduleName` is pinned so helpers resolve from `@babel/runtime`: the
// corejs3 polyfill provider above otherwise redirects them to the
// (uninstalled) `@babel/runtime-corejs3`, which preset-env used to suppress
// internally when it owned the polyfill injection via `useBuiltIns`.
[
"@babel/plugin-transform-runtime",
{ version: dependencies["@babel/runtime"] },
{ version: dependencies["@babel/runtime"], moduleName: "@babel/runtime" },
],
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-private-methods",
@@ -321,4 +305,22 @@ module.exports.config = {
isLandingPageBuild: true,
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
defineOverlay: {
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
__DEMO__: true,
},
isProdBuild,
latestBuild,
isStatsBuild,
};
},
};
+4
View File
@@ -1,9 +1,13 @@
// @ts-check
import globals from "globals";
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
languageOptions: {
globals: globals.node,
},
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
@@ -1,4 +1,3 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
+7
View File
@@ -45,3 +45,10 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
+41
View File
@@ -0,0 +1,41 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
"gen-icons-json",
"gen-pages-e2e-test-app-dev",
"build-translations",
"build-locale-data"
),
"copy-static-e2e-test-app",
"rspack-dev-server-e2e-test-app"
)
);
gulp.task(
"build-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app",
"gen-pages-e2e-test-app-prod"
)
);
+21 -1
View File
@@ -1,4 +1,3 @@
/* global process */
// Tasks to generate entry HTML
import {
@@ -268,3 +267,24 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
+2 -2
View File
@@ -1,7 +1,7 @@
import fs from "fs";
import { glob } from "glob";
import gulp from "gulp";
import yaml from "js-yaml";
import { load as loadYaml } from "js-yaml";
import { marked } from "marked";
import path from "path";
import paths from "../paths.cjs";
@@ -47,7 +47,7 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (descriptionContent.startsWith("---")) {
const metadataEnd = descriptionContent.indexOf("---", 3);
metadata = yaml.load(descriptionContent.substring(3, metadataEnd));
metadata = loadYaml(descriptionContent.substring(3, metadataEnd));
descriptionContent = descriptionContent
.substring(metadataEnd + 3)
.trim();
+20
View File
@@ -201,3 +201,23 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
// Copy app static files (icons, polyfills, etc.)
fs.copySync(
polyPath("public/static"),
path.resolve(paths.e2eTestApp_output_root, "static")
);
// Copy e2e test app public files (manifest, sw stubs)
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
if (fs.existsSync(e2ePublic)) {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
copyMdiIcons(paths.e2eTestApp_output_static);
});
+1
View File
@@ -4,6 +4,7 @@ import "./clean.js";
import "./compress.js";
import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
+69 -29
View File
@@ -1,6 +1,6 @@
// Gulp task to generate third-party license notices.
import { readFile, access } from "fs/promises";
import { readFile, access, readdir } from "fs/promises";
import { generateLicenseFile } from "generate-license-file";
import gulp from "gulp";
import path from "path";
@@ -11,58 +11,98 @@ const OUTPUT_FILE = path.join(
"third-party-licenses.txt"
);
const NODE_MODULES = path.resolve(paths.root_dir, "node_modules");
// The echarts package ships an Apache-2.0 NOTICE file that must be
// redistributed alongside the compiled output per Apache License §4(d).
const NOTICE_FILES = [
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
];
const NOTICE_FILES = [path.join(NODE_MODULES, "echarts/NOTICE")];
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
// Some packages need a manual license override (e.g. they ship multiple
// license files and we must pick the right one for the bundled code).
//
// Each entry is pinned to a specific version. If a package is updated,
// this list must be reviewed and the version updated after verifying
// that the new version's license still matches. The build will fail
// if the installed version does not match the pinned version.
// that the new version's license still matches. The build will fail if
// the pinned version is no longer installed.
const LICENSE_OVERRIDES = [
{
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.7.0",
licensePath: path.resolve(
paths.root_dir,
"node_modules/type-fest/license-mit"
),
licenseFile: "license-mit",
},
];
// Locate the directory of an installed package matching an exact version.
//
// The copy we care about may be hoisted to the top-level node_modules or
// nested under a dependency when a different version occupies the hoisted
// slot (e.g. a build-only dependency pulling in an older release). Searching
// both keeps this check independent of yarn's hoisting decisions, which can
// shift when unrelated dependencies are added.
async function findPackageDir(packageName, version) {
const candidateDirs = [path.join(NODE_MODULES, packageName)];
// Collect one level of nesting: node_modules/<dep>/node_modules/<pkg> and
// node_modules/@scope/<dep>/node_modules/<pkg>.
let topLevel = [];
try {
topLevel = await readdir(NODE_MODULES, { withFileTypes: true });
} catch {
// node_modules unreadable — fall back to the hoisted candidate only.
}
for (const entry of topLevel) {
if (!entry.isDirectory() || entry.name === packageName) {
continue;
}
if (entry.name.startsWith("@")) {
const scopeDir = path.join(NODE_MODULES, entry.name);
// eslint-disable-next-line no-await-in-loop
const scoped = await readdir(scopeDir, { withFileTypes: true }).catch(
() => []
);
for (const dep of scoped) {
if (dep.isDirectory()) {
candidateDirs.push(
path.join(scopeDir, dep.name, "node_modules", packageName)
);
}
}
} else {
candidateDirs.push(
path.join(NODE_MODULES, entry.name, "node_modules", packageName)
);
}
}
for (const dir of candidateDirs) {
// eslint-disable-next-line no-await-in-loop
const pkg = await readFile(path.join(dir, "package.json"), "utf-8")
.then(JSON.parse)
.catch(() => null);
if (pkg?.version === version) {
return dir;
}
}
return null;
}
gulp.task("gen-licenses", async () => {
const licenseOverrides = {};
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
const pkgJsonPath = path.resolve(
paths.root_dir,
`node_modules/${packageName}/package.json`
);
for (const { packageName, version, licenseFile } of LICENSE_OVERRIDES) {
// eslint-disable-next-line no-await-in-loop
const packageDir = await findPackageDir(packageName, version);
let packageJSON;
try {
// eslint-disable-next-line no-await-in-loop
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
} catch {
if (!packageDir) {
throw new Error(
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
);
}
if (packageJSON.version !== version) {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
`License override for "${packageName}" is pinned to version ${version}, but that version is not installed. ` +
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
);
}
const licensePath = path.join(packageDir, licenseFile);
try {
// eslint-disable-next-line no-await-in-loop
await access(licensePath);
+20
View File
@@ -14,6 +14,7 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -231,3 +232,22 @@ gulp.task("rspack-prod-landing-page", () =>
})
)
);
gulp.task("rspack-dev-server-e2e-test-app", () =>
runDevServer({
compiler: rspack(
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
})
);
gulp.task("rspack-prod-e2e-test-app", () =>
prodBuild(
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
+10 -4
View File
@@ -48,6 +48,12 @@ for (const buildType of ["Modern", "Legacy"]) {
const browserslistEnv = buildType.toLowerCase();
const babelOpts = babelOptions({ latestBuild: browserslistEnv === "modern" });
const presetEnvOpts = babelOpts.presets[0][1];
// Core-JS polyfills are injected by babel-plugin-polyfill-corejs3 (Babel 8
// removed preset-env's `useBuiltIns`), so read its options here.
const corejsOpts = babelOpts.plugins.find(
(plugin) =>
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
)?.[1];
// Invoking preset-env in debug mode will log the included plugins
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
@@ -59,16 +65,16 @@ for (const buildType of ["Modern", "Legacy"]) {
console.log(detailsClose);
// Manually log the Core-JS polyfills using the same technique
if (presetEnvOpts.useBuiltIns) {
if (corejsOpts) {
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
const targets = compilationTargets.default(babelOpts?.targets, {
browserslistEnv,
});
const polyfillList = coreJSCompat({ targets }).list.filter(
polyfillFilter(
`${presetEnvOpts.useBuiltIns}-global`,
presetEnvOpts?.corejs?.proposals,
presetEnvOpts?.shippedProposals
corejsOpts.method,
corejsOpts.proposals,
corejsOpts.shippedProposals
)
);
console.log(
@@ -0,0 +1,8 @@
/* global module */
module.exports = function litDisableDevModeLoader(source) {
return source.replace(
/\b(const|let|var) DEV_MODE = true;/g,
"$1 DEV_MODE = false;"
);
};
@@ -0,0 +1,63 @@
/* global module, require */
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
// tagged template literals using `minify-literals` (html-minifier-next +
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
//
// It runs between swc and babel: swc has already stripped TS types and
// decorators (so minify-literals' acorn parser only sees plain ESM), but the
// `html`/`css`/`svg` tagged templates are still intact at ES2021. Running after
// babel instead would miss the legacy build, where babel lowers the templates
// to `_taggedTemplateLiteral()` calls that no longer look like tagged templates.
const remapping = require("@ampproject/remapping");
// minify-literals is ESM-only, so load it via dynamic import from this CJS loader.
let minifyPromise;
const getMinifier = () => {
if (!minifyPromise) {
minifyPromise = import("minify-literals").then((m) => m.minifyHTMLLiterals);
}
return minifyPromise;
};
// HTML options mirror the previous babel-plugin-template-html-minifier config
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
// css`` templates and inline <style> is handled by minify-literals' lightningcss
// default.
const htmlOptions = {
caseSensitive: true,
collapseWhitespace: true,
conservativeCollapse: true,
decodeEntities: true,
removeComments: true,
removeRedundantAttributes: true,
};
module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
const callback = this.async();
getMinifier()
.then((minifyHTMLLiterals) =>
minifyHTMLLiterals(source, {
fileName: this.resourcePath,
html: htmlOptions,
})
)
.then((result) => {
if (!result) {
// No tagged templates changed; pass through untouched (incl. incoming map).
callback(null, source, map, meta);
return;
}
// minify-literals builds its map from `source` alone, so `result.map`
// describes minified output -> this loader's input (the swc output), not
// the original file. Compose it over the incoming map (swc output ->
// original source) so the map handed downstream still points at the
// original source; otherwise every minified file's source map is wrong.
const outMap =
map && result.map
? remapping([result.map, map], () => null)
: (result.map ?? map);
callback(null, result.code, outMap, meta);
})
.catch(callback);
};
+11
View File
@@ -50,4 +50,15 @@ module.exports = {
),
translations_src: path.resolve(__dirname, "../src/translations"),
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
e2eTestApp_output_static: path.resolve(
__dirname,
"../test/e2e/app/dist/static"
),
e2eTestApp_output_latest: path.resolve(
__dirname,
"../test/e2e/app/dist/frontend_latest"
),
};
+88 -19
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -48,6 +47,12 @@ const createRspackConfig = ({
dontHash = new Set();
}
const ignorePackages = bundle.ignorePackages({ latestBuild });
const litHtmlRoot = path.resolve(__dirname, "../node_modules/lit-html");
const litHtmlDevelopmentRoot = path.join(litHtmlRoot, "development");
const litDisableDevModeLoader = path.join(
__dirname,
"lit-disable-dev-mode-loader.cjs"
);
return {
name,
mode: isProdBuild ? "production" : "development",
@@ -67,25 +72,42 @@ const createRspackConfig = ({
{
test: /\.m?js$|\.ts$/,
exclude: /node_modules[\\/]core-js/,
use: (info) => [
{
loader: "babel-loader",
options: {
...bundle.babelOptions({
latestBuild,
isProdBuild,
isTestBuild,
sw: info.issuerLayer === "sw",
}),
cacheDirectory: !isProdBuild,
cacheCompression: false,
use: (info) =>
[
{
loader: "babel-loader",
options: {
...bundle.babelOptions({
latestBuild,
isTestBuild,
sw: info.issuerLayer === "sw",
}),
cacheDirectory: !isProdBuild,
cacheCompression: false,
},
},
},
{
loader: "builtin:swc-loader",
options: bundle.swcOptions(),
},
],
// Minify lit html/svg/css tagged template literals for production.
// Must run after swc (TS/decorators stripped, but templates kept at
// ES2021) and before babel — otherwise the legacy build lowers
// html`` to _taggedTemplateLiteral() calls that can no longer be
// matched, leaving legacy templates unminified.
isProdBuild && {
loader: path.join(
__dirname,
"minify-template-literals-loader.cjs"
),
},
!latestBuild &&
info.resource.startsWith(
`${litHtmlDevelopmentRoot}${path.sep}`
) && {
loader: litDisableDevModeLoader,
},
{
loader: "builtin:swc-loader",
options: bundle.swcOptions(),
},
].filter(Boolean),
resolve: {
fullySpecified: false,
},
@@ -132,6 +154,47 @@ const createRspackConfig = ({
// Only include the JS of entrypoints
filter: (file) => file.isInitial && !file.name.endsWith(".map"),
}),
// Babel can miscompile Lit's pre-minified runtime when downleveling to
// ES5. Compile lit-html from its development sources for legacy builds,
// then let the normal production minifier handle the final bundle.
!latestBuild &&
new rspack.NormalModuleReplacementPlugin(
/^(?:lit-html(?:\/.*)?|\.{1,2}\/.*\.js)$/,
(resource) => {
if (resource.request === "lit-html") {
resource.request = path.join(
litHtmlDevelopmentRoot,
"lit-html.js"
);
return;
}
if (resource.request.startsWith("lit-html/")) {
if (resource.request.startsWith("lit-html/development/")) {
return;
}
resource.request = path.join(
litHtmlDevelopmentRoot,
resource.request.slice("lit-html/".length)
);
return;
}
if (
resource.context.startsWith(`${litHtmlRoot}${path.sep}`) &&
resource.context !== litHtmlDevelopmentRoot &&
!resource.context.startsWith(
`${litHtmlDevelopmentRoot}${path.sep}`
)
) {
resource.request = path.join(
litHtmlDevelopmentRoot,
path.relative(
litHtmlRoot,
path.resolve(resource.context, resource.request)
)
);
}
}
),
new rspack.DefinePlugin(
bundle.definedVars({ isProdBuild, latestBuild, defineOverlay })
),
@@ -338,6 +401,11 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
createAppConfig,
createDemoConfig,
@@ -345,4 +413,5 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
+21
View File
@@ -0,0 +1,21 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [],
preferred_pipeline: null,
}));
// Stub for assist pipeline run — immediately sends run-end event so
// the UI does not hang waiting for a response.
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
if (onChange) {
onChange({
type: "run-end",
});
}
return null;
});
};
+5
View File
@@ -0,0 +1,5 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
+6
View File
@@ -234,6 +234,12 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
@@ -248,7 +248,7 @@ class DemoThermostatEntity extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
const hass = provideHass(this._demoRoot, {}, false, true);
hass.updateTranslations(null, "en");
hass.updateTranslations("lovelace", "en");
hass.addEntities(ENTITIES);
+4 -1
View File
@@ -1,5 +1,6 @@
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const SHADOWS = ["s", "m", "l"] as const;
@@ -17,7 +18,9 @@ export class DemoMiscBoxShadow extends LitElement {
(size) => html`
<div
class="box"
style="box-shadow: var(--ha-box-shadow-${size})"
style=${styleMap({
boxShadow: `var(--ha-box-shadow-${size})`,
})}
>
${size}
</div>
+1 -1
View File
@@ -151,7 +151,7 @@ class DemoMoreInfoClimate extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
const hass = provideHass(this._demoRoot, {}, false, true);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
+1 -1
View File
@@ -54,7 +54,7 @@ class DemoMoreInfoHumidifier extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
const hass = provideHass(this._demoRoot, {}, false, true);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
+19 -11
View File
@@ -22,13 +22,18 @@
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.29.7",
"@babel/runtime": "8.0.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.3",
@@ -97,7 +102,7 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.5",
"intl-messageformat": "11.2.8",
"js-yaml": "4.2.0",
"js-yaml": "5.0.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -127,10 +132,11 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.29.7",
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.1",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "7.29.7",
"@babel/preset-env": "7.29.7",
"@babel/plugin-transform-runtime": "8.0.1",
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.62.0",
@@ -138,26 +144,27 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.60.0",
"@rsdoctor/rspack-plugin": "1.5.15",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.0.3",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.21",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.1",
"@types/luxon": "3.7.2",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@vitest/coverage-v8": "4.1.9",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.5.0",
@@ -182,11 +189,12 @@
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.0.7",
"lint-staged": "17.0.8",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.0.2",
"pinst": "3.0.0",
"prettier": "3.8.4",
"rspack-manifest-plugin": "5.2.2",
@@ -216,6 +224,6 @@
},
"packageManager": "yarn@4.17.0",
"volta": {
"node": "24.17.0"
"node": "24.18.0"
}
}
@@ -125,7 +125,15 @@ export interface EntityPickerDisplay {
}
export const computeEntityPickerDisplay = (
hass: HomeAssistant,
hass: Pick<
HomeAssistant,
| "entities"
| "devices"
| "areas"
| "floors"
| "language"
| "translationMetadata"
>,
stateObj: HassEntity
): EntityPickerDisplay => {
const [entityName, deviceName, areaName] = computeEntityNameList(
+23 -7
View File
@@ -1,5 +1,5 @@
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { RelatedResult } from "../../data/search";
import type { ItemType, RelatedResult } from "../../data/search";
export interface RelatedIdSets {
areas: Set<string>;
@@ -8,14 +8,30 @@ export interface RelatedIdSets {
}
/**
* Build a set of related IDs for a given related result.
* Build a set of related IDs, merging in the current (queried) item.
* `search/related` does not echo the queried item back, but it is the closest
* related item (e.g. a card editor's own entity), so it is merged into the
* matching group when it is an area, device, or entity.
* @param related - The related result to build the sets from.
* @returns The related ID sets.
* @param current - The queried item to merge in.
* @returns The related ID sets, including the current item.
*/
export const buildRelatedIdSets = (related?: RelatedResult): RelatedIdSets => ({
areas: new Set(related?.area || []),
devices: new Set(related?.device || []),
entities: new Set(related?.entity || []),
export const buildRelatedIdSets = (
related?: RelatedResult,
current?: { itemType: ItemType; itemId: string }
): RelatedIdSets => ({
areas: new Set([
...(related?.area || []),
...(current?.itemType === "area" ? [current.itemId] : []),
]),
devices: new Set([
...(related?.device || []),
...(current?.itemType === "device" ? [current.itemId] : []),
]),
entities: new Set([
...(related?.entity || []),
...(current?.itemType === "entity" ? [current.itemId] : []),
]),
});
/**
@@ -1463,6 +1463,12 @@ export class HaDataTable extends LitElement {
flex: 1;
padding: var(--ha-space-3);
}
@media (min-width: 871px) {
ha-input-search {
--ha-input-search-height: 32px;
--ha-input-search-border-radius: 10px;
}
}
slot[name="header"] {
display: block;
}
+1 -9
View File
@@ -9,15 +9,13 @@ import {
} from "../../common/dom/fire_event";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { ValueChangedEvent } from "../../types";
import "../ha-sortable";
import "./ha-entity-picker";
import type { HaEntityPicker } from "./ha-entity-picker";
@customElement("ha-entities-picker")
class HaEntitiesPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Array }) public value?: string[];
@property({ type: Boolean }) public disabled = false;
@@ -87,10 +85,6 @@ class HaEntitiesPicker extends LitElement {
public reorder = false;
protected render() {
if (!this.hass) {
return nothing;
}
const currentEntities = this._currentEntities;
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
@@ -105,7 +99,6 @@ class HaEntitiesPicker extends LitElement {
<div class="entity">
<ha-entity-picker
.curValue=${entityId}
.hass=${this.hass}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeEntities=${this.includeEntities}
@@ -133,7 +126,6 @@ class HaEntitiesPicker extends LitElement {
</ha-sortable>
<div>
<ha-entity-picker
.hass=${this.hass}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeEntities=${this.includeEntities}
+125 -43
View File
@@ -1,4 +1,5 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiPlus, mdiShape } from "@mdi/js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -6,10 +7,21 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { RelatedIdSets } from "../../common/search/related-context";
import type { LocalizeFunc } from "../../common/translations/localize";
import {
configContext,
internationalizationContext,
registriesContext,
relatedContext,
statesContext,
} from "../../data/context";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import {
entityComboBoxKeys,
getEntities,
markEntitiesRelated,
sortEntitiesByRelatedRank,
type EntityComboBoxItem,
} from "../../data/entity/entity_picker";
import { domainToName } from "../../data/integration";
@@ -33,7 +45,21 @@ const CREATE_ID = "___create-new-entity___";
@customElement("ha-entity-picker")
export class HaEntityPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -131,12 +157,25 @@ export class HaEntityPicker extends LitElement {
@state() private _pendingEntityId?: string;
protected willUpdate(changedProperties: PropertyValues<this>) {
@state()
@consume({ context: relatedContext, subscribe: true })
private _relatedIdSets?: RelatedIdSets;
private get _hasRelatedContext(): boolean {
const related = this._relatedIdSets;
return (
!!related &&
(related.entities.size > 0 ||
related.devices.size > 0 ||
related.areas.size > 0)
);
}
protected willUpdate(changedProperties: PropertyValues) {
if (
this._pendingEntityId &&
changedProperties.has("hass") &&
this.hass.states !== changedProperties.get("hass")?.states &&
this.hass.states[this._pendingEntityId]
changedProperties.has("_states") &&
this._states[this._pendingEntityId]
) {
this._setValue(this._pendingEntityId);
this._pendingEntityId = undefined;
@@ -146,7 +185,7 @@ export class HaEntityPicker extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
// Load title translations so it is available when the combo-box opens
this.hass.loadBackendTranslation("title");
this._i18n.loadBackendTranslation("title");
}
private _findExtraOption(value: string | undefined) {
@@ -157,7 +196,7 @@ export class HaEntityPicker extends LitElement {
private _renderExtraOptionStart(extraOption: EntitySelectorExtraOption) {
const stateObj = extraOption.entity_id
? this.hass.states[extraOption.entity_id]
? this._states[extraOption.entity_id]
: undefined;
if (stateObj) {
return html`
@@ -193,7 +232,7 @@ export class HaEntityPicker extends LitElement {
`;
}
const stateObj = this.hass.states[entityId];
const stateObj = this._states[entityId];
if (!stateObj) {
return html`
@@ -207,7 +246,11 @@ export class HaEntityPicker extends LitElement {
}
const { primary, secondary } = computeEntityPickerDisplay(
this.hass,
{
...this._registries,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
stateObj
);
@@ -219,7 +262,7 @@ export class HaEntityPicker extends LitElement {
};
private get _showEntityId() {
return this.showEntityId || this.hass.userData?.showEntityIdPicker;
return this.showEntityId || this._config.userData?.showEntityIdPicker;
}
private _rowRenderer: RenderItemFunction<EntityComboBoxItem> = (
@@ -267,17 +310,14 @@ export class HaEntityPicker extends LitElement {
};
private _getAdditionalItems = () =>
this._getCreateItems(this.hass.localize, this.createDomains);
this._getCreateItems(this._i18n.localize, this.createDomains);
private _getCreateItems = memoizeOne(
(
localize: this["hass"]["localize"],
createDomains: this["createDomains"]
) => {
(localize: LocalizeFunc, createDomains: this["createDomains"]) => {
if (!createDomains?.length) {
return [];
}
this.hass.loadFragmentTranslation("config");
this._i18n.loadFragmentTranslation("config");
return createDomains.map((domain) => {
const primary = localize(
"ui.components.entity.entity-picker.create_helper",
@@ -302,7 +342,9 @@ export class HaEntityPicker extends LitElement {
private _getEntitiesMemoized = memoizeOne(
(
hass: HomeAssistant,
states: ContextType<typeof statesContext>,
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
@@ -312,21 +354,46 @@ export class HaEntityPicker extends LitElement {
excludeEntities?: string[],
value?: string
) =>
getEntities(hass, {
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
})
getEntities(
{
states,
...registries,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
localize: i18n.localize,
},
{
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
}
)
);
private _sortByRelatedContext = memoizeOne(
(
items: EntityComboBoxItem[],
related: RelatedIdSets,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
language: string
): EntityComboBoxItem[] =>
sortEntitiesByRelatedRank(
markEntitiesRelated(items, related, entities, devices),
language
)
);
private _getItems = () => {
const items = this._getEntitiesMemoized(
this.hass,
const entityItems = this._getEntitiesMemoized(
this._states,
this._registries,
this._i18n,
this.includeDomains,
this.excludeDomains,
this.entityFilter,
@@ -336,14 +403,23 @@ export class HaEntityPicker extends LitElement {
this.excludeEntities,
this.value
);
const sortedItems = this._hasRelatedContext
? this._sortByRelatedContext(
entityItems,
this._relatedIdSets!,
this._registries.entities,
this._registries.devices,
this._i18n.locale.language
)
: entityItems;
if (this.extraOptions?.length) {
const resolvedExtras = this.extraOptions.map((opt) => ({
...opt,
stateObj: opt.entity_id ? this.hass.states[opt.entity_id] : undefined,
stateObj: opt.entity_id ? this._states[opt.entity_id] : undefined,
}));
return [...resolvedExtras, ...items];
return [...resolvedExtras, ...sortedItems];
}
return items;
return sortedItems;
};
private _shouldHideClearIcon() {
@@ -353,11 +429,10 @@ export class HaEntityPicker extends LitElement {
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.entity.entity-picker.placeholder");
this._i18n.localize("ui.components.entity.entity-picker.placeholder");
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
@@ -375,12 +450,13 @@ export class HaEntityPicker extends LitElement {
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
.searchKeys=${entityComboBoxKeys}
.noSort=${this._hasRelatedContext}
use-top-label
.addButtonLabel=${this.addButton
? (this.addButtonLabel ??
this.hass.localize("ui.components.entity.entity-picker.add"))
this._i18n.localize("ui.components.entity.entity-picker.add"))
: undefined}
.unknownItemText=${this.hass.localize(
.unknownItemText=${this._i18n.localize(
"ui.components.entity.entity-picker.unknown"
)}
@value-changed=${this._valueChanged}
@@ -393,17 +469,23 @@ export class HaEntityPicker extends LitElement {
search,
filteredItems
) => {
// Float related items to the top by closeness, keeping search relevance
// order within each tier.
const items = this._hasRelatedContext
? sortEntitiesByRelatedRank(filteredItems)
: filteredItems;
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex(
const index = items.findIndex(
(item) => item.stateObj?.entity_id === search
);
if (index === -1) {
return filteredItems;
return items;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
const [exactMatch] = items.splice(index, 1);
items.unshift(exactMatch);
return items;
};
public async open() {
@@ -427,7 +509,7 @@ export class HaEntityPicker extends LitElement {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) {
if (this.hass.states[item.entityId]) {
if (this._states[item.entityId]) {
this._setValue(item.entityId);
} else {
this._pendingEntityId = item.entityId;
@@ -453,7 +535,7 @@ export class HaEntityPicker extends LitElement {
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.entity.entity-picker.no_match", {
this._i18n.localize("ui.components.entity.entity-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
@@ -173,7 +173,6 @@ export class HaAreaControlsPicker extends LitElement {
domainItems = multiTermSortedSearch(
domainItems,
searchString,
this._domainSearchKeys,
(item) => item.id,
fuseIndex
);
@@ -226,7 +225,6 @@ export class HaAreaControlsPicker extends LitElement {
entityItems = multiTermSortedSearch(
entityItems,
searchString,
this._entitySearchKeys,
(item) => item.id,
fuseIndex
);
+12 -5
View File
@@ -1,10 +1,11 @@
import { consume, type ContextType } from "@lit/context";
import { mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { getAreaContext } from "../common/entity/context/get_area_context";
import type { HomeAssistant } from "../types";
import { areasContext, floorsContext } from "../data/context";
import "./ha-expansion-panel";
import "./ha-items-display-editor";
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
@@ -17,7 +18,13 @@ export interface AreasDisplayValue {
@customElement("ha-areas-display-editor")
export class HaAreasDisplayEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@consume({ context: areasContext, subscribe: true })
@state()
private _areas!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
@state()
private _floors!: ContextType<typeof floorsContext>;
@property() public label?: string;
@@ -35,10 +42,10 @@ export class HaAreasDisplayEditor extends LitElement {
public showNavigationButton = false;
protected render(): TemplateResult {
const areas = Object.values(this.hass.areas);
const areas = Object.values(this._areas);
const items: DisplayItem[] = areas.map((area) => {
const { floor } = getAreaContext(area, this.hass.floors);
const { floor } = getAreaContext(area, this._floors);
return {
value: area.area_id,
label: area.name,
@@ -1,15 +1,19 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, 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 { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import type { LocalizeFunc } from "../common/translations/localize";
import { areasContext, floorsContext } from "../data/context";
import type { FloorRegistryEntry } from "../data/floor_registry";
import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { ValueChangedEvent } from "../types";
import "./ha-expansion-panel";
import "./ha-floor-icon";
import "./ha-items-display-editor";
@@ -30,7 +34,17 @@ const UNASSIGNED_FLOOR = "__unassigned__";
@customElement("ha-areas-floors-display-editor")
export class HaAreasFloorsDisplayEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: areasContext, subscribe: true })
@state()
private _areas!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
@state()
private _floors!: ContextType<typeof floorsContext>;
@property() public label?: string;
@@ -51,13 +65,14 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
protected render(): TemplateResult {
const groupedAreasItems = this._groupedAreasItems(
this.hass.areas,
this.hass.floors
this._areas,
this._floors
);
const filteredFloors = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
this._floors,
this.value?.floors_display?.order,
this._localize
).filter(
(floor) =>
// Only include floors that have areas assigned to them
@@ -124,15 +139,14 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
private _groupedAreasItems = memoizeOne(
(
hassAreas: HomeAssistant["areas"],
// update items if floors change
_hassFloors: HomeAssistant["floors"]
areas: ContextType<typeof areasContext>,
floors: ContextType<typeof floorsContext>
): Record<string, DisplayItem[]> => {
const areas = Object.values(hassAreas);
const areaList = Object.values(areas);
const groupedItems: Record<string, DisplayItem[]> = areas.reduce(
const groupedItems: Record<string, DisplayItem[]> = areaList.reduce(
(acc, area) => {
const { floor } = getAreaContext(area, this.hass.floors);
const { floor } = getAreaContext(area, floors);
const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR;
if (!acc[floorId]) {
@@ -155,23 +169,24 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
private _sortedFloors = memoizeOne(
(
hassFloors: HomeAssistant["floors"],
order: string[] | undefined
floors: ContextType<typeof floorsContext>,
order: string[] | undefined,
localize: LocalizeFunc
): FloorRegistryEntry[] => {
const floors = getFloors(hassFloors, order);
const noFloors = floors.length === 0;
floors.push({
const sortedFloors = getFloors(floors, order);
const noFloors = sortedFloors.length === 0;
sortedFloors.push({
floor_id: UNASSIGNED_FLOOR,
name: noFloors
? this.hass.localize("ui.panel.lovelace.strategy.areas.areas")
: this.hass.localize("ui.panel.lovelace.strategy.areas.other_areas"),
? localize("ui.panel.lovelace.strategy.areas.areas")
: localize("ui.panel.lovelace.strategy.areas.other_areas"),
icon: null,
level: null,
aliases: [],
created_at: 0,
modified_at: 0,
});
return floors;
return sortedFloors;
}
);
@@ -180,8 +195,9 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
const floorIds = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
this._floors,
this.value?.floors_display?.order,
this._localize
).map((floor) => floor.floor_id);
const newOrder = [...floorIds];
const movedFloorId = newOrder.splice(oldIndex, 1)[0];
@@ -204,8 +220,9 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
const currentFloorId = (ev.currentTarget as any).floorId;
const floorIds = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
this._floors,
this.value?.floors_display?.order,
this._localize
).map((floor) => floor.floor_id);
const oldAreaDisplay = this.value?.areas_display ?? {};
@@ -223,14 +240,14 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
continue;
}
const hidden = oldHidden.filter((areaId) => {
const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
const id = this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
if (hidden?.length) {
newHidden.push(...hidden);
}
const order = oldOrder.filter((areaId) => {
const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
const id = this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
if (order?.length) {
+57 -17
View File
@@ -1,10 +1,18 @@
import { consume, type ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeEntityStates } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateName } from "../common/entity/compute_state_name";
import {
configContext,
connectionContext,
entitiesContext,
} from "../data/context";
import { entityIcon } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-items-display-editor";
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
@@ -15,7 +23,21 @@ export interface EntitiesDisplayValue {
@customElement("ha-entities-display-editor")
export class HaEntitiesDisplayEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeEntityStates({ entityIdPath: ["entitiesIds"] })
private _entityStates?: Record<string, HassEntity>;
@consume({ context: entitiesContext, subscribe: true })
@state()
private _entitiesReg!: ContextType<typeof entitiesContext>;
@consume({ context: configContext, subscribe: true })
@state()
private _config!: ContextType<typeof configContext>;
@consume({ context: connectionContext, subscribe: true })
@state()
private _connection!: ContextType<typeof connectionContext>;
@property() public label?: string;
@@ -32,20 +54,13 @@ export class HaEntitiesDisplayEditor extends LitElement {
@property({ type: Boolean }) public required = false;
protected render(): TemplateResult {
const entities = this.entitiesIds
.map((entityId) => this.hass.states[entityId])
.filter(Boolean);
const items: DisplayItem[] = entities.map((entity) => ({
value: entity.entity_id,
label: computeStateName(entity),
icon: entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
entity
),
}));
const items = this._items(
this.entitiesIds,
this._entityStates,
this._entitiesReg,
this._config,
this._connection
);
const value: DisplayValue = {
order: this.value?.order ?? [],
@@ -61,6 +76,31 @@ export class HaEntitiesDisplayEditor extends LitElement {
`;
}
private _items = memoizeOne(
(
entitiesIds: string[],
entityStates: Record<string, HassEntity> | undefined,
entitiesReg: ContextType<typeof entitiesContext>,
config: ContextType<typeof configContext>,
connection: ContextType<typeof connectionContext>
): DisplayItem[] => {
const entities = entitiesIds
.map((entityId) => entityStates?.[entityId])
.filter((stateObj): stateObj is HassEntity => Boolean(stateObj));
return entities.map((entity) => ({
value: entity.entity_id,
label: computeStateName(entity),
icon: entityIcon(
entitiesReg,
config.config,
connection.connection,
entity
),
}));
}
);
private _itemDisplayChanged(ev) {
ev.stopPropagation();
const value = ev.detail.value as DisplayValue;
-3
View File
@@ -5,7 +5,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
@@ -14,8 +13,6 @@ import "./ha-list";
@customElement("ha-filter-states")
export class HaFilterStates extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: string[];
-1
View File
@@ -243,7 +243,6 @@ export class HaNavigationPicker extends LitElement {
items = multiTermSortedSearch(
items,
searchString,
DEFAULT_SEARCH_KEYS,
(item) => item.id,
fuseIndex
);
-1
View File
@@ -492,7 +492,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
this._allItems,
searchString,
this.searchKeys || DEFAULT_SEARCH_KEYS,
(item) => item.id,
index
);
+27 -25
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { mdiCamera } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
@@ -8,9 +9,11 @@ import { customElement, property, query, state } from "lit/decorators";
// WebAssembly port of ZXing:
import { prepareZXingModule } from "barcode-detector";
import type QrScanner from "qr-scanner";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { configContext } from "../data/context";
import { addExternalBarCodeListener } from "../external_app/external_app_entrypoint";
import type { HomeAssistant } from "../types";
import "./ha-alert";
import "./ha-button";
import "./ha-dropdown";
@@ -33,7 +36,13 @@ prepareZXingModule({
@customElement("ha-qr-scanner")
class HaQrScanner extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@property() public description?: string;
@@ -106,7 +115,7 @@ class HaQrScanner extends LitElement {
${this._error || this._warning}
${this._error
? html`<ha-button @click=${this._retry} slot="action">
${this.hass.localize("ui.components.qr-scanner.retry")}
${this._localize("ui.components.qr-scanner.retry")}
</ha-button>`
: nothing}
</ha-alert>`
@@ -126,7 +135,7 @@ class HaQrScanner extends LitElement {
? html`<ha-dropdown @wa-select=${this._handleDropdownSelect}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize(
.label=${this._localize(
"ui.components.qr-scanner.select_camera"
)}
.path=${mdiCamera}
@@ -146,28 +155,24 @@ class HaQrScanner extends LitElement {
</div>`
: html`<ha-alert alert-type="warning">
${!window.isSecureContext
? this.hass.localize(
"ui.components.qr-scanner.only_https_supported"
)
: this.hass.localize("ui.components.qr-scanner.not_supported")}
? this._localize("ui.components.qr-scanner.only_https_supported")
: this._localize("ui.components.qr-scanner.not_supported")}
</ha-alert>
<p>${this.hass.localize("ui.components.qr-scanner.manual_input")}</p>
<p>${this._localize("ui.components.qr-scanner.manual_input")}</p>
<div class="row">
<ha-input
.label=${this.hass.localize(
"ui.components.qr-scanner.enter_qr_code"
)}
.label=${this._localize("ui.components.qr-scanner.enter_qr_code")}
@keyup=${this._manualKeyup}
@paste=${this._manualPaste}
></ha-input>
<ha-button @click=${this._manualSubmit}>
${this.hass.localize("ui.common.submit")}
${this._localize("ui.common.submit")}
</ha-button>
</div>`}`;
}
private get _nativeBarcodeScanner(): boolean {
return Boolean(this.hass.auth.external?.config.hasBarCodeScanner);
return Boolean(this._config.auth.external?.config.hasBarCodeScanner);
}
private async _loadQrScanner() {
@@ -182,7 +187,7 @@ class HaQrScanner extends LitElement {
const QrScanner = (await import("qr-scanner")).default;
if (!(await QrScanner.hasCamera())) {
this._reportError(
this.hass.localize("ui.components.qr-scanner.no_camera_found")
this._localize("ui.components.qr-scanner.no_camera_found")
);
return;
}
@@ -270,7 +275,7 @@ class HaQrScanner extends LitElement {
if (msg.command === "bar_code/scan_result") {
if (msg.payload.format !== "qr_code") {
this._notifyExternalScanner(
this.hass.localize("ui.components.qr-scanner.wrong_code", {
this._localize("ui.components.qr-scanner.wrong_code", {
format: msg.payload.format,
rawValue: msg.payload.rawValue,
})
@@ -288,20 +293,17 @@ class HaQrScanner extends LitElement {
}
return true;
});
this.hass.auth.external!.fireMessage({
this._config.auth.external!.fireMessage({
type: "bar_code/scan",
payload: {
title:
this.title ||
this.hass.localize("ui.components.qr-scanner.app.title"),
this.title || this._localize("ui.components.qr-scanner.app.title"),
description:
this.description ||
this.hass.localize("ui.components.qr-scanner.app.description"),
this._localize("ui.components.qr-scanner.app.description"),
alternative_option_label:
this.alternativeOptionLabel ||
this.hass.localize(
"ui.components.qr-scanner.app.alternativeOptionLabel"
),
this._localize("ui.components.qr-scanner.app.alternativeOptionLabel"),
},
});
}
@@ -309,7 +311,7 @@ class HaQrScanner extends LitElement {
private _closeExternalScanner() {
this._removeListener?.();
this._removeListener = undefined;
this.hass.auth.external!.fireMessage({
this._config.auth.external!.fireMessage({
type: "bar_code/close",
});
}
@@ -318,7 +320,7 @@ class HaQrScanner extends LitElement {
if (!this._nativeBarcodeScanner) {
return;
}
this.hass.auth.external!.fireMessage({
this._config.auth.external!.fireMessage({
type: "bar_code/notify",
payload: {
message,
@@ -23,7 +23,6 @@ export class HaAreasDisplaySelector extends LitElement {
protected render() {
return html`
<ha-areas-display-editor
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -4,7 +4,6 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { ColorTempSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-labeled-slider";
import { generateColorTemperatureGradient } from "../../dialogs/more-info/components/lights/light-color-temp-picker";
import {
@@ -15,8 +14,6 @@ import {
@customElement("ha-selector-color_temp")
export class HaColorTempSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: ColorTempSelector;
@property() public value?: string;
@@ -63,7 +63,6 @@ export class HaEntitySelector extends LitElement {
if (!this.selector.entity?.multiple) {
return html`<ha-entity-picker
.hass=${this.hass}
.value=${typeof this.value === "string" ? this.value : ""}
.label=${this.label}
.placeholder=${this.placeholder}
@@ -80,7 +79,6 @@ export class HaEntitySelector extends LitElement {
return html`
<ha-entities-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.placeholder=${this.placeholder}
@@ -84,7 +84,6 @@ export class HaLocationSelector extends LitElement {
<p>${this.label ? this.label : ""}</p>
<ha-locations-editor
class="flex"
.hass=${this.hass}
.helper=${this.helper}
.locations=${this._location(this.selector, this.value)}
@location-updated=${this._locationChanged}
@@ -91,7 +91,6 @@ export class HaMediaSelector extends LitElement {
? nothing
: html`
<ha-entity-picker
.hass=${this.hass}
.value=${entityId}
.label=${this.label ||
this.hass.localize(
@@ -1,5 +1,6 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiClose, mdiConnection, mdiMemory, mdiPencil, mdiUsb } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
@@ -405,11 +406,12 @@ export class HaSerialPortSelector extends LitElement {
}
let groupItems: SerialPickerItem[] = grouped[type];
if (searchString) {
const fuseIndex = Fuse.createIndex(DEFAULT_SEARCH_KEYS, groupItems);
groupItems = multiTermSortedSearch(
groupItems,
searchString,
DEFAULT_SEARCH_KEYS,
(item) => item.id
(item) => item.id,
fuseIndex
);
}
if (!groupItems.length) {
@@ -3,15 +3,13 @@ import { customElement, property, query } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { StringSelector } from "../../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { ValueChangedEvent } from "../../types";
import "../ha-textarea";
import "../input/ha-input";
import "../input/ha-input-multi";
@customElement("ha-selector-text")
export class HaTextSelector extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public value?: any;
@property() public name?: string;
-1
View File
@@ -558,7 +558,6 @@ export class HaServiceControl extends LitElement {
></ha-settings-row>`
: entityId
? html`<ha-entity-picker
.hass=${this.hass}
.disabled=${this.disabled}
.value=${this._value?.data?.entity_id}
.label=${this.hass.localize(
-1
View File
@@ -1116,7 +1116,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return multiTermSortedSearch(
items,
searchTerm,
weightedKeys,
(item) => item.id,
fuseIndex
);
+2 -3
View File
@@ -1,5 +1,5 @@
import type { Schema } from "js-yaml";
import { DEFAULT_SCHEMA, dump, load } from "js-yaml";
import { dump, load, YAML11_SCHEMA } from "js-yaml";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -30,7 +30,7 @@ const isEmpty = (obj: Record<string, unknown>): boolean => {
export class HaYamlEditor extends LitElement {
@property() public value?: any;
@property({ attribute: false }) public yamlSchema: Schema = DEFAULT_SCHEMA;
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
@property({ attribute: false }) public defaultValue?: any;
@@ -70,7 +70,6 @@ export class HaYamlEditor extends LitElement {
this._yaml = !isEmpty(value)
? dump(value, {
schema: this.yamlSchema,
quotingType: '"',
noRefs: true,
})
: "";
+5 -1
View File
@@ -41,7 +41,11 @@ export class HaInputSearch extends HaInput {
...HaInput.styles,
css`
:host([appearance="outlined"]) wa-input.no-label::part(base) {
height: 40px;
height: var(--ha-input-search-height, 40px);
border-radius: var(
--ha-input-search-border-radius,
var(--ha-border-radius-md)
);
}
`,
];
+1 -3
View File
@@ -13,7 +13,7 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { HomeAssistant, ThemeMode } from "../../types";
import type { ThemeMode } from "../../types";
import "../ha-input-helper-text";
import "./ha-map";
import type { HaMap } from "./ha-map";
@@ -45,8 +45,6 @@ export interface MarkerLocation {
@customElement("ha-locations-editor")
export class HaLocationsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public locations?: MarkerLocation[];
@property() public helper?: string;
@@ -100,7 +100,6 @@ class DialogJoinMediaPlayers extends LitElement {
: nothing}
<div class="content">
<ha-media-player-toggle
.hass=${this.hass}
.entityId=${entityId}
checked
disabled
@@ -108,7 +107,6 @@ class DialogJoinMediaPlayers extends LitElement {
${this._mediaPlayerEntities(this.hass.entities).map(
(entity) =>
html`<ha-media-player-toggle
.hass=${this.hass}
.entityId=${entity.entity_id}
.checked=${this._selectedEntities.includes(entity.entity_id)}
@change=${this._handleSelectedChange}
@@ -1,35 +1,66 @@
import { type CSSResultGroup, LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { consume, type ContextType } from "@lit/context";
import { mdiSpeaker, mdiSpeakerPause, mdiSpeakerPlay } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { type CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HomeAssistant } from "../../types";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeRTL } from "../../common/util/compute_rtl";
import { fireEvent } from "../../common/dom/fire_event";
import {
areasContext,
devicesContext,
entitiesContext,
floorsContext,
internationalizationContext,
} from "../../data/context";
import "../ha-switch";
import "../ha-svg-icon";
@customElement("ha-media-player-toggle")
class HaMediaPlayerToggle extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@property({ type: Boolean }) public checked = false;
@property({ type: Boolean }) public disabled = false;
@state()
@consumeEntityState({ entityIdPath: ["entityId"] })
private _stateObj?: HassEntity;
@consume({ context: entitiesContext, subscribe: true })
@state()
private _entities!: ContextType<typeof entitiesContext>;
@consume({ context: devicesContext, subscribe: true })
@state()
private _devices!: ContextType<typeof devicesContext>;
@consume({ context: areasContext, subscribe: true })
@state()
private _areas!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
@state()
private _floors!: ContextType<typeof floorsContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
private _computeDisplayData = memoizeOne(
(
entityId: string,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
entities: ContextType<typeof entitiesContext>,
devices: ContextType<typeof devicesContext>,
areas: ContextType<typeof areasContext>,
floors: ContextType<typeof floorsContext>,
isRTL: boolean,
stateObj: HomeAssistant["states"][string]
stateObj: HassEntity
) => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
@@ -50,7 +81,11 @@ class HaMediaPlayerToggle extends LitElement {
);
protected render() {
const stateObj = this.hass.states[this.entityId];
const stateObj = this._stateObj;
if (!stateObj) {
return nothing;
}
let icon = mdiSpeaker;
if (stateObj.state === "playing") {
@@ -60,16 +95,16 @@ class HaMediaPlayerToggle extends LitElement {
}
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
this._i18n.language,
this._i18n.translationMetadata.translations
);
const { primary, secondary } = this._computeDisplayData(
this.entityId,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors,
this._entities,
this._devices,
this._areas,
this._floors,
isRTL,
stateObj
);
+2 -2
View File
@@ -7,8 +7,8 @@ import { customElement, property } from "lit/decorators";
export class HaProgressRing extends ProgressRing {
@property() public size?: "tiny" | "small" | "medium" | "large";
public updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("size")) {
switch (this.size) {
-4
View File
@@ -44,7 +44,6 @@ import type {
IfActionTraceStep,
TraceExtended,
} from "../../data/trace";
import type { HomeAssistant } from "../../types";
import "../ha-icon-button";
import "../ha-service-icon";
import "./hat-graph-branch";
@@ -76,8 +75,6 @@ export class HatScriptGraph extends LitElement {
@query("hat-graph-node[active], hat-graph-branch[active]")
private _activeNode?: HTMLElement;
public hass!: HomeAssistant;
public renderedNodes: Record<string, NodeInfo> = {};
public trackedNodes: Record<string, NodeInfo> = {};
@@ -457,7 +454,6 @@ export class HatScriptGraph extends LitElement {
${node.action
? html`<ha-service-icon
slot="icon"
.hass=${this.hass}
.service=${node.action}
></ha-service-icon>`
: nothing}
+3 -3
View File
@@ -1,7 +1,7 @@
import { mdiMapClock, mdiShape } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { HomeAssistant } from "../types";
import type { AutomationElementGroupCollection } from "./automation";
import type { Selector, TargetSelector } from "./selector";
@@ -68,10 +68,10 @@ export interface ConditionDescription {
export type ConditionDescriptions = Record<string, ConditionDescription>;
export const subscribeConditions = (
hass: HomeAssistant,
connection: Connection,
callback: (conditions: ConditionDescriptions) => void
) =>
hass.connection.subscribeMessage<ConditionDescriptions>(callback, {
connection.subscribeMessage<ConditionDescriptions>(callback, {
type: "condition_platforms/subscribe",
});
+45 -1
View File
@@ -1,6 +1,6 @@
import { createContext } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type {
HomeAssistant,
HomeAssistantApi,
@@ -12,11 +12,13 @@ import type {
HomeAssistantUI,
} from "../../types";
import type { RelatedIdSets } from "../../common/search/related-context";
import type { ConditionDescriptions } from "../condition";
import type { ConfigEntry } from "../config_entries";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import type { DomainManifestLookup } from "../integration";
import type { LabelRegistryEntry } from "../label/label_registry";
import type { ItemType } from "../search";
import type { TriggerDescriptions } from "../trigger";
/**
* Entity, device, area, and floor registries
@@ -131,6 +133,19 @@ export const configEntriesContext =
export const manifestsContext =
createContext<DomainManifestLookup>("manifests");
/**
* Lazy loaded trigger platform descriptions, keyed by trigger key.
*/
export const triggerDescriptionsContext = createContext<TriggerDescriptions>(
"triggerDescriptions"
);
/**
* Lazy loaded condition platform descriptions, keyed by condition key.
*/
export const conditionDescriptionsContext =
createContext<ConditionDescriptions>("conditionDescriptions");
// #endregion lazy-contexts
// #region deprecated-contexts
@@ -196,4 +211,33 @@ declare global {
}
}
/**
* Set the related context to an entity (or clear it when no entity), so nearby
* pickers float relevant entities.
* @param node - The node to fire the event on.
* @param context - The context to set, or undefined to clear.
*/
export const fireRelatedContext = (
node: HTMLElement,
context: RelatedContextItem | undefined
): void => {
fireEvent(node, "hass-related-context", context);
};
/**
* Set the related context to an entity (or clear it when no entity), so nearby
* pickers float relevant entities. Fired by editors.
* @param node - The node to fire the event on.
* @param entityId - The entity to set, or undefined to clear.
*/
export const fireEntityRelatedContext = (
node: HTMLElement,
entityId: string | undefined
): void => {
fireRelatedContext(
node,
entityId ? { itemType: "entity", itemId: entityId } : undefined
);
};
// #endregion related-context
+42 -7
View File
@@ -42,6 +42,12 @@ import {
export const ENERGY_COLLECTION_KEY_PREFIX = "energy_";
// Collection key for the statistics-based energy dashboard views (Overview,
// Electricity, Gas, Water).
export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard";
// Collection key for the real-time "Now" view (live power + 5-minute stats).
export const DEFAULT_POWER_COLLECTION_KEY = "energy_dashboard_now";
// All collection keys created this session
const energyCollectionKeys = new Set<string | undefined>();
@@ -787,9 +793,30 @@ const findEnergyDataCollection = (
return (hass.connection as any)[key];
};
// When does the collection's day period need to roll over to the next day?
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
// midnight. Otherwise it waits an hour, until the new day's first hourly
// statistic exists — rolling over at midnight would show an empty graph.
export const getNextEnergyPeriodStart = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"]
): Date => {
const dayEnd = calcDate(now, endOfDay, locale, config);
return midnightRollover ? addMilliseconds(dayEnd, 1) : addHours(dayEnd, 1);
};
export const getEnergyDataCollection = (
hass: HomeAssistant,
options: { prefs?: EnergyPreferences; key?: string } = {}
options: {
prefs?: EnergyPreferences;
key?: string;
// The real-time "Now" view opts in to rolling its day period over at
// midnight rather than an hour later (it shows live data, so it always
// tracks today and never falls back to yesterday in the first hour).
midnightRollover?: boolean;
} = {}
): EnergyCollection => {
const [key, collectionKey] = convertCollectionKeyToConnection(
hass,
@@ -799,6 +826,8 @@ export const getEnergyDataCollection = (
return (hass.connection as any)[key];
}
const midnightRollover = options.midnightRollover ?? false;
energyCollectionKeys.add(collectionKey);
const collection = getCollection<EnergyData>(
@@ -857,12 +886,16 @@ export const getEnergyDataCollection = (
const now = new Date();
const hour = formatTime24h(now, hass.locale, hass.config).split(":")[0];
// Set start to start of today if we have data for today, otherwise yesterday
// Set start to start of today if we have data for today, otherwise yesterday.
// The real-time "Now" view always tracks today; it shows live data even
// before today's first statistic exists, so it never falls back to yesterday.
const preferredPeriod =
(localStorage.getItem(`energy-default-period-${key}`) as DateRange) ||
"today";
const period =
preferredPeriod === "today" && hour === "0" ? "yesterday" : preferredPeriod;
preferredPeriod === "today" && hour === "0" && !midnightRollover
? "yesterday"
: preferredPeriod;
const [start, end] = calcDateRange(hass.locale, hass.config, period);
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
@@ -886,10 +919,12 @@ export const getEnergyDataCollection = (
collection.refresh();
scheduleUpdatePeriod();
},
addHours(
calcDate(new Date(), endOfDay, hass.locale, hass.config),
1
).getTime() - Date.now() // Switch to next day an hour after the day changed
getNextEnergyPeriodStart(
midnightRollover,
new Date(),
hass.locale,
hass.config
).getTime() - Date.now()
);
};
scheduleUpdatePeriod();
+84 -1
View File
@@ -1,7 +1,10 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { getEntityAreaId } from "../../common/entity/context/get_entity_context";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { RelatedIdSets } from "../../common/search/related-context";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
@@ -12,6 +15,7 @@ import type { HaEntityPickerEntityFilterFunc } from "./entity";
export interface EntityComboBoxItem extends PickerComboBoxItem {
domain_name?: string;
stateObj?: HassEntity;
relatedRank?: number;
}
export const entityComboBoxKeys: FuseWeightedKey[] = [
@@ -54,7 +58,17 @@ export interface GetEntitiesOptions {
}
export const getEntities = (
hass: HomeAssistant,
hass: Pick<
HomeAssistant,
| "states"
| "entities"
| "devices"
| "areas"
| "floors"
| "language"
| "translationMetadata"
| "localize"
>,
options?: GetEntitiesOptions
): EntityComboBoxItem[] => {
const {
@@ -186,3 +200,72 @@ export const getEntities = (
return items;
};
const RELATED_RANK_UNRELATED = 3;
const entityRelatedRank = (
entityId: string | undefined,
related: RelatedIdSets,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): number => {
if (!entityId) {
return RELATED_RANK_UNRELATED;
}
if (related.entities.has(entityId)) {
return 0;
}
const deviceId = entities[entityId]?.device_id;
if (deviceId && related.devices.has(deviceId)) {
return 1;
}
const areaId = getEntityAreaId(entityId, entities, devices);
if (areaId && related.areas.has(areaId)) {
return 2;
}
return RELATED_RANK_UNRELATED;
};
/**
* Annotate entity items with their closeness to the related context, so they
* can be floated to the top. The entity itself ranks closest, then its device,
* then its area; anything unrelated keeps the lowest rank.
*/
export const markEntitiesRelated = (
items: EntityComboBoxItem[],
related: RelatedIdSets,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): EntityComboBoxItem[] =>
items.map((item) => ({
...item,
relatedRank: entityRelatedRank(
item.stateObj?.entity_id,
related,
entities,
devices
),
}));
/**
* Sort entity items by related closeness (entity, then device, then area, then
* the rest). Pass `language` to break ties within a tier alphabetically by
* label; omit it to keep the incoming order (e.g. search relevance).
*/
export const sortEntitiesByRelatedRank = (
items: EntityComboBoxItem[],
language?: string
): EntityComboBoxItem[] =>
[...items].sort((a, b) => {
const rankDiff =
(a.relatedRank ?? RELATED_RANK_UNRELATED) -
(b.relatedRank ?? RELATED_RANK_UNRELATED);
if (rankDiff !== 0 || language === undefined) {
return rankDiff;
}
return caseInsensitiveStringCompare(
a.sorting_label ?? "",
b.sorting_label ?? "",
language
);
});
+12
View File
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { LovelaceCardFeatureContext } from "../panels/lovelace/card-features/types";
import type { LovelaceBadgeConfig } from "./lovelace/config/badge";
import type { LovelaceCardConfig } from "./lovelace/config/card";
export interface CustomCardSuggestion<
@@ -10,6 +11,13 @@ export interface CustomCardSuggestion<
config: T;
}
export interface CustomBadgeSuggestion<
T extends LovelaceBadgeConfig = LovelaceBadgeConfig,
> {
label?: string;
config: T;
}
export interface CustomCardEntry {
type: string;
name?: string;
@@ -28,6 +36,10 @@ export interface CustomBadgeEntry {
description?: string;
preview?: boolean;
documentationURL?: string;
getEntitySuggestion?: (
hass: HomeAssistant,
entityId: string
) => CustomBadgeSuggestion | CustomBadgeSuggestion[] | null;
}
export interface CustomCardFeatureEntry {
+3 -3
View File
@@ -1,8 +1,8 @@
import { mdiMapClock, mdiShape } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { HomeAssistant } from "../types";
import type {
AutomationElementGroupCollection,
Trigger,
@@ -73,10 +73,10 @@ export interface TriggerDescription {
export type TriggerDescriptions = Record<string, TriggerDescription>;
export const subscribeTriggers = (
hass: HomeAssistant,
connection: Connection,
callback: (triggers: TriggerDescriptions) => void
) =>
hass.connection.subscribeMessage<TriggerDescriptions>(callback, {
connection.subscribeMessage<TriggerDescriptions>(callback, {
type: "trigger_platforms/subscribe",
});
+2 -19
View File
@@ -59,14 +59,12 @@ import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../data/entity/entity_registry";
import { subscribeLabFeature } from "../../data/labs";
import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import type { EntitySettingsState } from "../../panels/config/entities/entity-registry-settings-editor";
import type { Helper } from "../../panels/config/helpers/const";
import { ScrollableFadeMixin } from "../../mixins/scrollable-fade-mixin";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
@@ -126,7 +124,7 @@ const DEFAULT_VIEW: MoreInfoView = "info";
export class MoreInfoDialog extends DirtyStateProviderMixin<
EntitySettingsState | Helper | Record<string, string[]> | null,
"entity-registry" | "helper" | "vacuum-segment-mapping"
>()(SubscribeMixin(ScrollableFadeMixin(LitElement))) {
>()(ScrollableFadeMixin(LitElement)) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@@ -163,8 +161,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _isEscapeEnabled = true;
@state() private _newTriggersAndConditions = false;
protected scrollFadeThreshold = 24;
protected get scrollableElement(): HTMLElement | null {
@@ -260,24 +256,11 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
private _shouldShowAddEntityTo(): boolean {
return (
(this._newTriggersAndConditions && !!this.hass.user?.is_admin) ||
!!this.hass.user?.is_admin ||
!!this.hass.auth.external?.config.hasEntityAddTo
);
}
protected hassSubscribe() {
return [
subscribeLabFeature(
this.hass.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
}
),
];
}
private _getDeviceId(): string | null {
const entity = this.hass.entities[this._entityId!] as
| EntityRegistryEntry
@@ -42,7 +42,7 @@ export class MoreInfoLogbook extends LitElement {
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._entityIdAsList(this.entityId)}
.scope=${"entity"}
name-detail="none"
narrow
no-icon
graph-color
+6 -11
View File
@@ -460,8 +460,7 @@ export class QuickBar extends LitElement {
navigateItems = this._filterGroup(
"navigate",
navigateItems,
filter,
navigateComboBoxKeys
filter
) as NavigationComboBoxItem[];
}
@@ -482,8 +481,7 @@ export class QuickBar extends LitElement {
commandItems = this._filterGroup(
"command",
commandItems,
filter,
commandComboBoxKeys
filter
) as ActionCommandComboBoxItem[];
}
@@ -513,8 +511,7 @@ export class QuickBar extends LitElement {
this._filterGroup(
"entity",
entityItems,
filter,
entityComboBoxKeys
filter
) as EntityComboBoxItem[]
);
} else {
@@ -550,7 +547,7 @@ export class QuickBar extends LitElement {
if (filter) {
deviceItems = sortRelatedFirst(
this._filterGroup("device", deviceItems, filter, deviceComboBoxKeys)
this._filterGroup("device", deviceItems, filter)
);
} else {
deviceItems = this._sortRelatedByLabel(deviceItems);
@@ -582,7 +579,7 @@ export class QuickBar extends LitElement {
if (filter) {
areaItems = sortRelatedFirst(
this._filterGroup("area", areaItems, filter, areaComboBoxKeys)
this._filterGroup("area", areaItems, filter)
);
} else {
areaItems = this._sortRelatedByLabel(areaItems);
@@ -660,15 +657,13 @@ export class QuickBar extends LitElement {
private _filterGroup(
type: QuickBarSection,
items: PickerComboBoxItem[],
searchTerm: string,
weightedKeys: FuseWeightedKey[]
searchTerm: string
) {
const fuseIndex = this._fuseIndexes[type](items);
return multiTermSortedSearch(
items,
searchTerm,
weightedKeys,
(item: PickerComboBoxItem) => item.id,
fuseIndex
);
+59 -1
View File
@@ -1,3 +1,4 @@
import { ContextProvider } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import {
applyThemesOnElement,
@@ -6,6 +7,16 @@ import {
import { fireEvent } from "../common/dom/fire_event";
import { computeFormatFunctions } from "../common/translations/entity-state";
import { computeLocalize } from "../common/translations/localize";
import {
apiContext,
configContext,
connectionContext,
formattersContext,
internationalizationContext,
registriesContext,
uiContext,
} from "../data/context";
import { updateHassGroups } from "../data/context/updateContext";
import type { IconCategory } from "../data/icons";
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry";
import {
@@ -85,13 +96,55 @@ export interface MockHomeAssistant extends HomeAssistant {
export const provideHass = (
elements,
overrideData: Partial<HomeAssistant> = {},
setHassProperty = false
setHassProperty = false,
// Opt-in to providing the grouped Lit contexts (config, formatters, api, …)
// that the real app's root element provides via `contextMixin`. Needed for
// gallery demos that render context-consuming components (e.g. the climate
// temperature control) without the full app shell.
provideContexts = false
): MockHomeAssistant => {
elements = ensureArray(elements);
// Can happen because we store sidebar, more info etc on hass.
const baseEl = () => elements[0];
const hass = (): MockHomeAssistant => baseEl().hass;
const contextProviders = provideContexts
? {
registries: new ContextProvider(baseEl(), {
context: registriesContext,
}),
internationalization: new ContextProvider(baseEl(), {
context: internationalizationContext,
}),
api: new ContextProvider(baseEl(), { context: apiContext }),
connection: new ContextProvider(baseEl(), {
context: connectionContext,
}),
ui: new ContextProvider(baseEl(), { context: uiContext }),
config: new ContextProvider(baseEl(), { context: configContext }),
formatters: new ContextProvider(baseEl(), {
context: formattersContext,
}),
}
: undefined;
const updateContextProviders = (newHass: HomeAssistant) => {
if (!contextProviders) {
return;
}
(
Object.keys(contextProviders) as (keyof typeof contextProviders)[]
).forEach((group) => {
const provider = contextProviders[group];
provider.setValue(
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
newHass,
provider.value
)
);
});
};
const wsCommands = {};
const restResponses: [string | RegExp, MockRestCallback][] = [];
@@ -396,6 +449,7 @@ export const provideHass = (
elements.forEach((el) => {
el.hass = newHass;
});
updateContextProviders(newHass);
},
updateStates,
updateTranslations,
@@ -457,6 +511,10 @@ export const provideHass = (
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
},
],
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
...overrideData,
};
@@ -793,6 +793,12 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
ha-input-search {
flex: 1;
}
@media (min-width: 871px) {
ha-input-search {
--ha-input-search-height: 32px;
--ha-input-search-border-radius: 10px;
}
}
.search-toolbar {
display: flex;
align-items: center;
-1
View File
@@ -176,7 +176,6 @@ class OnboardingLocation extends LitElement {
</div>
<ha-locations-editor
class="flex"
.hass=${this.hass}
.locations=${this._markerLocations(
this._location,
this._places,
@@ -228,7 +228,6 @@ class DialogCalendarEventEditor extends DirtyStateProviderMixin<CalendarEventFor
></ha-textarea>
<ha-entity-picker
name="calendar"
.hass=${this.hass}
.label=${this.hass.localize("ui.components.calendar.label")}
.value=${this._calendarId!}
.includeDomains=${CALENDAR_DOMAINS}
@@ -1,5 +1,5 @@
import { mdiDotsVertical } from "@mdi/js";
import { DEFAULT_SCHEMA, Type } from "js-yaml";
import { defineScalarTag, YAML11_SCHEMA } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -47,12 +47,11 @@ const SUPPORTED_UI_TYPES = [
"schema",
];
const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
new Type("!secret", {
kind: "scalar",
construct: (data) => `!secret ${data}`,
}),
]);
const secretTag = defineScalarTag("!secret", {
resolve: (data) => `!secret ${data}`,
});
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
const MASKED_FIELDS = ["password", "secret", "token"];
@@ -251,7 +251,6 @@ class DialogAreaDetail
>
<div class="content">
<ha-entity-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.areas.editor.temperature_entity"
)}
@@ -266,7 +265,6 @@ class DialogAreaDetail
></ha-entity-picker>
<ha-entity-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.areas.editor.humidity_entity"
)}
+26 -59
View File
@@ -14,20 +14,14 @@ import {
mdiShape,
mdiTools,
} from "@mdi/js";
import type {
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import type { HassEntity } from "home-assistant-js-websocket/dist/types";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
} from "../../../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
@@ -54,7 +48,7 @@ import {
updateAreaRegistryEntry,
} from "../../../data/area/area_registry";
import type { AutomationEntity } from "../../../data/automation";
import { fullEntitiesContext } from "../../../data/context";
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import { sortDeviceRegistryByName } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
@@ -62,7 +56,6 @@ import {
computeEntityRegistryName,
sortEntityRegistryByName,
} from "../../../data/entity/entity_registry";
import { subscribeLabFeature } from "../../../data/labs";
import type { SceneEntity } from "../../../data/scene";
import type { ScriptEntity } from "../../../data/script";
import type { RelatedResult } from "../../../data/search";
@@ -72,7 +65,6 @@ import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
@@ -146,7 +138,7 @@ const NAVIGATION_ACTIONS: {
const MAX_COLUMNS = 3;
@customElement("ha-config-area-page")
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
class HaConfigAreaPage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public areaId!: string;
@@ -161,8 +153,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@state() private _related?: RelatedResult;
@state() private _newTriggersConditions = false;
private _logbookTime = { recent: 86400 };
private _columnsController = createColumnsController(this, MAX_COLUMNS);
@@ -251,30 +241,13 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
super.updated(changedProps);
if (changedProps.has("areaId")) {
this._findRelated();
fireEvent(this, "hass-related-context", {
fireRelatedContext(this, {
itemType: "area",
itemId: this.areaId,
});
}
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
if (!isComponentLoaded(this.hass!.config, "automation")) {
return [];
}
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersConditions = feature.enabled;
}
),
];
}
protected render() {
if (!this.hass.areas || !this.hass.devices || !this.hass.entities) {
return nothing;
@@ -380,32 +353,26 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
></ha-icon-button>
</div>`
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>`
: nothing}
</div>`}
<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.dialogs.more_info_control.add_to.item")}
</ha-button>
</div>
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
@@ -622,7 +589,7 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
.time=${this._logbookTime}
.entityIds=${this._allEntities(memberships)}
.deviceIds=${this._allDeviceIds(memberships.devices)}
.scope=${"area"}
name-detail="device"
virtualize
narrow
no-icon
@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -21,10 +22,9 @@ import {
CONDITION_BUILDING_BLOCKS,
getConditionDomain,
getConditionObjectId,
subscribeConditions,
} from "../../../../../data/condition";
import { conditionDescriptionsContext } from "../../../../../data/context";
import { domainToName } from "../../../../../data/integration";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
import "../../condition/ha-automation-condition-editor";
import type HaAutomationConditionEditor from "../../condition/ha-automation-condition-editor";
@@ -42,10 +42,7 @@ import "../../condition/types/ha-automation-condition-zone";
import type { ActionElement } from "../ha-automation-action-row";
@customElement("ha-automation-action-condition")
export class HaConditionAction
extends SubscribeMixin(LitElement)
implements ActionElement
{
export class HaConditionAction extends LitElement implements ActionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@@ -58,7 +55,9 @@ export class HaConditionAction
@property({ type: Boolean, attribute: "indent" }) public indent = false;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@query("ha-automation-condition-editor")
private _conditionEditor?: HaAutomationConditionEditor;
@@ -67,21 +66,6 @@ export class HaConditionAction
return { condition: "state" };
}
protected hassSubscribe() {
return [
subscribeConditions(this.hass, (conditions) =>
this._addConditions(conditions)
),
];
}
private _addConditions(conditions: ConditionDescriptions) {
this._conditionDescriptions = {
...this._conditionDescriptions,
...conditions,
};
}
protected render() {
const buildingBlock = CONDITION_BUILDING_BLOCKS.includes(
this.action.condition
@@ -7,10 +7,7 @@ import {
mdiHelpCircleOutline,
mdiPlus,
} from "@mdi/js";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -80,13 +77,16 @@ import {
CONDITION_COLLECTIONS,
getConditionDomain,
getConditionObjectId,
subscribeConditions,
} from "../../../data/condition";
import {
getConfigEntries,
type ConfigEntry,
} from "../../../data/config_entries";
import { labelsContext } from "../../../data/context";
import {
conditionDescriptionsContext,
labelsContext,
triggerDescriptionsContext,
} from "../../../data/context";
import { getDeviceEntityLookup } from "../../../data/device/device_registry";
import type { EntityComboBoxItem } from "../../../data/entity/entity_picker";
import { getFloorAreaLookup } from "../../../data/floor_registry";
@@ -101,7 +101,6 @@ import {
fetchIntegrationManifests,
} from "../../../data/integration";
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
import { subscribeLabFeature } from "../../../data/labs";
import { filterSelectorEntities } from "../../../data/selector";
import {
TARGET_SEPARATOR,
@@ -116,7 +115,6 @@ import {
TRIGGER_COLLECTIONS,
getTriggerDomain,
getTriggerObjectId,
subscribeTriggers,
} from "../../../data/trigger";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
@@ -227,7 +225,13 @@ class DialogAddAutomationElement
@state() private _narrow = false;
@state() private _triggerDescriptions: TriggerDescriptions = {};
@state()
@consume({ context: triggerDescriptionsContext, subscribe: true })
private _triggerDescriptions: TriggerDescriptions = {};
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@state() private _targetItems?: {
title: string;
@@ -236,12 +240,8 @@ class DialogAddAutomationElement
@state() private _loadItemsError = false;
@state() private _newTriggersAndConditions = false;
@state() private _openedFromQuery = false;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
@@ -259,10 +259,6 @@ class DialogAddAutomationElement
// #region variables
private _unsub?: Promise<UnsubscribeFunc>;
private _unsubscribeLabFeatures?: Promise<UnsubscribeFunc>;
private _configEntryLookup: Record<string, ConfigEntry> = {};
private _closing = false;
@@ -278,31 +274,6 @@ class DialogAddAutomationElement
) {
this._calculateUsedDomains();
}
if (changedProps.has("_newTriggersAndConditions")) {
this._subscribeDescriptions();
}
}
private _subscribeDescriptions() {
this._unsubscribe();
if (this._params?.type === "trigger") {
this._triggerDescriptions = {};
this._unsub = subscribeTriggers(this.hass, (triggers) => {
this._triggerDescriptions = {
...this._triggerDescriptions,
...triggers,
};
});
} else if (this._params?.type === "condition") {
this._conditionDescriptions = {};
this._unsub = subscribeConditions(this.hass, (conditions) => {
this._conditionDescriptions = {
...this._conditionDescriptions,
...conditions,
};
});
}
}
public showDialog(params: AddAutomationElementDialogParams): void {
@@ -334,28 +305,9 @@ class DialogAddAutomationElement
this._loadConfigEntries();
this._unsubscribe();
this._fetchManifests();
this._calculateUsedDomains();
this._unsubscribeLabFeatures = subscribeLabFeature(
this.hass.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
this._tab = this._newTriggersAndConditions ? "targets" : "groups";
if (
queryTarget &&
this._newTriggersAndConditions &&
!this._selectedTarget
) {
this._selectedTarget = queryTarget;
this._getItemsByTarget();
}
}
);
if (!queryTarget) {
// add initial dialog view state to history
mainWindow.history.pushState(
@@ -372,11 +324,9 @@ class DialogAddAutomationElement
} else if (this._params?.type === "trigger") {
this.hass.loadBackendTranslation("triggers");
getTriggerIcons(this.hass.connection, this.hass.config);
this._subscribeDescriptions();
} else if (this._params?.type === "condition") {
this.hass.loadBackendTranslation("conditions");
getConditionIcons(this.hass.connection, this.hass.config);
this._subscribeDescriptions();
}
window.addEventListener("resize", this._updateNarrow);
@@ -385,11 +335,7 @@ class DialogAddAutomationElement
// prevent view mode switch when resizing window
this._bottomSheetMode = this._narrow;
if (
queryTarget &&
this._newTriggersAndConditions &&
!this._selectedTarget
) {
if (queryTarget && !this._selectedTarget) {
this._selectedTarget = queryTarget;
this._tab = "targets";
this._getItemsByTarget();
@@ -434,7 +380,6 @@ class DialogAddAutomationElement
}
this.removeKeyboardShortcuts();
this._unsubscribe();
if (this._params) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -450,7 +395,7 @@ class DialogAddAutomationElement
this._selectedCollectionIndex = undefined;
this._selectedGroup = undefined;
this._selectedTarget = undefined;
this._tab = this._newTriggersAndConditions ? "targets" : "groups";
this._tab = "targets";
this._filter = "";
this._manifests = undefined;
this._domains = undefined;
@@ -589,7 +534,6 @@ class DialogAddAutomationElement
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("resize", this._updateNarrow);
this._unsubscribe();
}
protected supportedShortcuts(): SupportedShortcuts {
@@ -598,39 +542,10 @@ class DialogAddAutomationElement
};
}
private _unsubscribe() {
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
if (this._unsubscribeLabFeatures) {
this._unsubscribeLabFeatures.then((unsub) => unsub());
this._unsubscribeLabFeatures = undefined;
}
}
// #endregion lifecycle
// #region render
private _getEmptyNote(automationElementType: string) {
if (
automationElementType !== "trigger" &&
automationElementType !== "condition"
) {
return undefined;
}
return this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target_note`,
{
labs_link: html`<a href="/config/labs" @click=${this._close}
>${this.hass.localize("ui.panel.config.labs.caption")}</a
>`,
}
);
}
protected render() {
if (!this._params) {
return nothing;
@@ -664,6 +579,12 @@ class DialogAddAutomationElement
const automationElementType = this._params!.type;
const tabButtons = [
{
label: this.hass.localize(
"ui.panel.config.automation.editor.tabs.target"
),
value: "targets",
},
{
label: this.hass.localize(
"ui.panel.config.automation.editor.tabs.type"
@@ -672,15 +593,6 @@ class DialogAddAutomationElement
},
];
if (this._newTriggersAndConditions) {
tabButtons.unshift({
label: this.hass.localize(
"ui.panel.config.automation.editor.tabs.target"
),
value: "targets",
});
}
if (this._params?.type !== "trigger") {
tabButtons.push({
label: this.hass.localize("ui.panel.config.automation.editor.blocks"),
@@ -763,7 +675,6 @@ class DialogAddAutomationElement
this._manifests
)}
.convertToItem=${this._convertToItem}
.newTriggersAndConditions=${this._newTriggersAndConditions}
@search-element-picked=${this._searchItemSelected}
>
</ha-automation-add-search>`
@@ -889,7 +800,6 @@ class DialogAddAutomationElement
.emptyLabel=${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target`
)}
.emptyNote=${this._getEmptyNote(automationElementType)}
.tooltipDescription=${this._tab === "targets"}
.target=${(this._tab === "targets" &&
this._selectedTarget &&
@@ -1,5 +1,5 @@
import { mdiInformationOutline, mdiPlus } from "@mdi/js";
import { LitElement, css, html, nothing, type TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import {
customElement,
eventOptions,
@@ -40,8 +40,6 @@ export class HaAutomationAddItems extends LitElement {
@property({ attribute: "empty-label" }) public emptyLabel!: string;
@property({ attribute: false }) public emptyNote?: string | TemplateResult;
@property({ attribute: false }) public target?: Target;
@property({ attribute: false }) public getLabel!: (
@@ -83,9 +81,6 @@ export class HaAutomationAddItems extends LitElement {
? html`${this.emptyLabel}
${this.target
? html`<div>${this._renderTarget(this.target)}</div>`
: nothing}
${this.emptyNote
? html`<div class="empty-note">${this.emptyNote}</div>`
: nothing}`
: repeat(
this.items,
@@ -232,17 +227,6 @@ export class HaAutomationAddItems extends LitElement {
justify-content: center;
}
.empty-note {
color: var(--ha-color-text-secondary);
margin-top: var(--ha-space-2);
text-align: center;
}
.empty-note a {
color: currentColor;
text-decoration: underline;
}
.items.error {
background-color: var(--ha-color-fill-danger-quiet-resting);
color: var(--ha-color-on-danger-normal);
@@ -117,9 +117,6 @@ export class HaAutomationAddSearch extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: "new-triggers-and-conditions" })
public newTriggersAndConditions = false;
@property({ attribute: false })
public convertToItem!: (
key: string,
@@ -209,7 +206,6 @@ export class HaAutomationAddSearch extends LitElement {
this.filter,
this.configEntryLookup,
this.items,
this.newTriggersAndConditions,
this._selectedSearchSection,
this._relatedIdSets
);
@@ -260,19 +256,13 @@ export class HaAutomationAddSearch extends LitElement {
}
private _renderSections() {
if (this.addElementType === "trigger" && !this.newTriggersAndConditions) {
return nothing;
}
const searchSections: ("separator" | SearchSection)[] = ["item"];
if (this.addElementType !== "trigger") {
searchSections.push("block");
}
if (this.newTriggersAndConditions) {
searchSections.push(...TARGET_SEARCH_SECTIONS);
}
searchSections.push(...TARGET_SEARCH_SECTIONS);
return html`
<ha-chip-set class="sections">
${searchSections.map((section) =>
@@ -502,7 +492,6 @@ export class HaAutomationAddSearch extends LitElement {
searchTerm: string,
configEntryLookup: Record<string, ConfigEntry>,
automationItems: AddAutomationElementListItem[],
newTriggersAndConditions: boolean,
selectedSection?: SearchSection,
relatedIdSets?: RelatedIdSets
) => {
@@ -570,191 +559,185 @@ export class HaAutomationAddSearch extends LitElement {
resultItems.push(...blocks);
}
if (newTriggersAndConditions) {
if (!selectedSection || selectedSection === "entity") {
let entityItems = this._getEntitiesMemoized(
this.hass,
`entity${TARGET_SEPARATOR}`
);
if (!selectedSection || selectedSection === "entity") {
let entityItems = this._getEntitiesMemoized(
this.hass,
`entity${TARGET_SEPARATOR}`
);
if (relatedIdSets?.entities.size) {
entityItems = entityItems.map((item) => ({
...item,
isRelated: relatedIdSets.entities.has(
(item as EntityComboBoxItem).stateObj?.entity_id || ""
),
})) as EntityComboBoxItem[];
}
if (searchTerm) {
entityItems = sortRelatedFirst(
this._filterGroup(
"entity",
entityItems,
searchTerm,
entityComboBoxKeys
)
) as EntityComboBoxItem[];
} else if (relatedIdSets?.entities.size) {
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
}
if (!selectedSection && entityItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.entities")
);
}
resultItems.push(...entityItems);
}
if (!selectedSection || selectedSection === "device") {
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
`device${TARGET_SEPARATOR}`
);
if (relatedIdSets?.devices.size) {
deviceItems = deviceItems.map((item) => ({
...item,
isRelated: relatedIdSets.devices.has(
item.id.split(TARGET_SEPARATOR)[1] || ""
),
}));
}
if (searchTerm) {
deviceItems = sortRelatedFirst(
this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
)
);
} else if (relatedIdSets?.devices.size) {
deviceItems = sortRelatedFirst(deviceItems);
}
if (!selectedSection && deviceItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.devices")
);
}
resultItems.push(...deviceItems);
}
if (!selectedSection || selectedSection === "area") {
let areasAndFloors = this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(TARGET_SEPARATOR)
if (relatedIdSets?.entities.size) {
entityItems = entityItems.map((item) => ({
...item,
isRelated: relatedIdSets.entities.has(
(item as EntityComboBoxItem).stateObj?.entity_id || ""
),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
);
if (relatedIdSets?.areas.size) {
areasAndFloors = areasAndFloors.map((item) => ({
...item,
isRelated:
item.type === "area"
? relatedIdSets.areas.has(
item.id.split(TARGET_SEPARATOR)[1] || ""
)
: false,
})) as FloorComboBoxItem[];
}
if (searchTerm) {
areasAndFloors = sortRelatedFirst(
this._filterGroup(
"area",
areasAndFloors,
searchTerm,
areaFloorComboBoxKeys,
false
)
) as FloorComboBoxItem[];
} else if (relatedIdSets?.areas.size) {
areasAndFloors = sortRelatedFirst(
areasAndFloors
) as FloorComboBoxItem[];
}
if (!selectedSection && areasAndFloors.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.areas")
);
}
resultItems.push(
...areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
) {
return {
...item,
last: true,
};
}
return item;
})
);
})) as EntityComboBoxItem[];
}
if (!selectedSection || selectedSection === "label") {
let labels = this._getLabelsMemoized(
this.hass.states,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._labelRegistry,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
`label${TARGET_SEPARATOR}`
);
if (searchTerm) {
labels = this._filterGroup(
"label",
labels,
if (searchTerm) {
entityItems = sortRelatedFirst(
this._filterGroup(
"entity",
entityItems,
searchTerm,
labelComboBoxKeys
);
}
if (!selectedSection && labels.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.labels")
);
}
resultItems.push(...labels);
entityComboBoxKeys
)
) as EntityComboBoxItem[];
} else if (relatedIdSets?.entities.size) {
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
}
if (!selectedSection && entityItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.entities")
);
}
resultItems.push(...entityItems);
}
if (!selectedSection || selectedSection === "device") {
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
`device${TARGET_SEPARATOR}`
);
if (relatedIdSets?.devices.size) {
deviceItems = deviceItems.map((item) => ({
...item,
isRelated: relatedIdSets.devices.has(
item.id.split(TARGET_SEPARATOR)[1] || ""
),
}));
}
if (searchTerm) {
deviceItems = sortRelatedFirst(
this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
)
);
} else if (relatedIdSets?.devices.size) {
deviceItems = sortRelatedFirst(deviceItems);
}
if (!selectedSection && deviceItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.devices")
);
}
resultItems.push(...deviceItems);
}
if (!selectedSection || selectedSection === "area") {
let areasAndFloors = this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(TARGET_SEPARATOR)
),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
);
if (relatedIdSets?.areas.size) {
areasAndFloors = areasAndFloors.map((item) => ({
...item,
isRelated:
item.type === "area"
? relatedIdSets.areas.has(
item.id.split(TARGET_SEPARATOR)[1] || ""
)
: false,
})) as FloorComboBoxItem[];
}
if (searchTerm) {
areasAndFloors = sortRelatedFirst(
this._filterGroup(
"area",
areasAndFloors,
searchTerm,
areaFloorComboBoxKeys,
false
)
) as FloorComboBoxItem[];
} else if (relatedIdSets?.areas.size) {
areasAndFloors = sortRelatedFirst(
areasAndFloors
) as FloorComboBoxItem[];
}
if (!selectedSection && areasAndFloors.length) {
// show group title
resultItems.push(localize("ui.components.target-picker.type.areas"));
}
resultItems.push(
...areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
) {
return {
...item,
last: true,
};
}
return item;
})
);
}
if (!selectedSection || selectedSection === "label") {
let labels = this._getLabelsMemoized(
this.hass.states,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._labelRegistry,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
`label${TARGET_SEPARATOR}`
);
if (searchTerm) {
labels = this._filterGroup(
"label",
labels,
searchTerm,
labelComboBoxKeys
);
}
if (!selectedSection && labels.length) {
// show group title
resultItems.push(localize("ui.components.target-picker.type.labels"));
}
resultItems.push(...labels);
}
return resultItems;
@@ -779,7 +762,6 @@ export class HaAutomationAddSearch extends LitElement {
return multiTermSortedSearch<PickerComboBoxItem>(
items,
searchTerm,
searchKeys,
(item) => item.id,
fuseIndex
);
@@ -1,9 +1,7 @@
import { consume } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
@@ -19,12 +17,8 @@ import {
type Condition,
} from "../../../../data/automation";
import type { ConditionDescriptions } from "../../../../data/condition";
import {
CONDITION_BUILDING_BLOCKS,
subscribeConditions,
} from "../../../../data/condition";
import { subscribeLabFeature } from "../../../../data/labs";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import { conditionDescriptionsContext } from "../../../../data/context";
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
import {
getAddAutomationElementTargetFromQuery,
@@ -38,7 +32,7 @@ import type HaAutomationConditionRow from "./ha-automation-condition-row";
@customElement("ha-automation-condition")
export default class HaAutomationCondition extends AutomationSortableListMixin<Condition>(
SubscribeMixin(LitElement)
LitElement
) {
@property({ attribute: false }) public conditions!: Condition[];
@@ -48,16 +42,13 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
@property({ type: Boolean, attribute: false }) public editorDirty = false;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@queryAll("ha-automation-condition-row")
private _conditionRowElements?: HaAutomationConditionRow[];
// @ts-ignore
@state() private _newTriggersAndConditions = false;
private _unsub?: Promise<UnsubscribeFunc>;
private _openedAddDialogFromQuery = false;
protected get items(): Condition[] {
@@ -72,49 +63,6 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
this.highlightedConditions = items;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe();
}
protected hassSubscribe() {
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
}
),
];
}
private _subscribeDescriptions() {
this._unsubscribe();
this._conditionDescriptions = {};
this._unsub = subscribeConditions(this.hass, (descriptions) => {
this._conditionDescriptions = {
...this._conditionDescriptions,
...descriptions,
};
});
}
private _unsubscribe() {
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (changedProperties.has("_newTriggersAndConditions")) {
this._subscribeDescriptions();
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("conditions");
@@ -38,7 +38,6 @@ export class HaZoneCondition extends LitElement {
)}
.value=${entity_id}
@value-changed=${this._entityPicked}
.hass=${this.hass}
.disabled=${this.disabled}
.entityFilter=${zoneAndLocationFilter}
></ha-entity-picker>
@@ -48,7 +47,6 @@ export class HaZoneCondition extends LitElement {
)}
.value=${zone}
@value-changed=${this._zonePicked}
.hass=${this.hass}
.disabled=${this.disabled}
.includeDomains=${includeDomains}
></ha-entity-picker>
@@ -8,12 +8,11 @@ import type {
import { css, html } from "lit";
import { property, state } from "lit/decorators";
import { transform } from "../../../common/decorators/transform";
import { fireEvent } from "../../../common/dom/fire_event";
import { goBack, navigate } from "../../../common/navigate";
import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-spinner"; // used by renderLoading() provided to both editors
import { fullEntitiesContext } from "../../../data/context";
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
showAlertDialog,
@@ -167,9 +166,8 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
}
this._relatedContextAreaId = areaId;
fireEvent(
fireRelatedContext(
this,
"hass-related-context",
areaId
? {
itemType: "area",
@@ -34,7 +34,7 @@ import type {
NodeInfo,
} from "../../../components/trace/hat-script-graph";
import type { AutomationEntity } from "../../../data/automation";
import { fullEntitiesContext } from "../../../data/context";
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { LogbookEntry } from "../../../data/logbook";
import { getLogbookDataForContext } from "../../../data/logbook";
@@ -228,7 +228,6 @@ export class HaAutomationTrace extends LitElement {
<div class="main">
<div class="graph">
<hat-script-graph
.hass=${this.hass}
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
@@ -378,9 +377,8 @@ export class HaAutomationTrace extends LitElement {
(entry) => entry.entity_id === this._entityId
)?.area_id
: undefined;
fireEvent(
fireRelatedContext(
this,
"hass-related-context",
areaId
? {
itemType: "area",
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { load } from "js-yaml";
import { load, YAML11_SCHEMA } from "js-yaml";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
@@ -224,7 +224,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
let loaded: any;
try {
loaded = load(paste);
loaded = load(paste, { schema: YAML11_SCHEMA });
} catch (_err: any) {
showEditorToast(this, {
message: this.hass.localize(
@@ -1,9 +1,7 @@
import { consume } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -19,10 +17,9 @@ import {
type Trigger,
type TriggerList,
} from "../../../../data/automation";
import { subscribeLabFeature } from "../../../../data/labs";
import { triggerDescriptionsContext } from "../../../../data/context";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { isTriggerList } from "../../../../data/trigger";
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
import {
getAddAutomationElementTargetFromQuery,
@@ -36,7 +33,7 @@ import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
@customElement("ha-automation-trigger")
export default class HaAutomationTrigger extends AutomationSortableListMixin<Trigger>(
SubscribeMixin(LitElement)
LitElement
) {
@property({ attribute: false }) public triggers!: Trigger[];
@@ -46,12 +43,9 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
@property({ type: Boolean, attribute: false }) public editorDirty = false;
@state() private _triggerDescriptions: TriggerDescriptions = {};
// @ts-ignore
@state() private _newTriggersAndConditions = false;
private _unsub?: Promise<UnsubscribeFunc>;
@state()
@consume({ context: triggerDescriptionsContext, subscribe: true })
private _triggerDescriptions: TriggerDescriptions = {};
private _openedAddDialogFromQuery = false;
@@ -67,49 +61,6 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
this.highlightedTriggers = items;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe();
}
protected hassSubscribe() {
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
}
),
];
}
private _subscribeDescriptions() {
this._unsubscribe();
this._triggerDescriptions = {};
this._unsub = subscribeTriggers(this.hass, (descriptions) => {
this._triggerDescriptions = {
...this._triggerDescriptions,
...descriptions,
};
});
}
private _unsubscribe() {
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (changedProperties.has("_newTriggersAndConditions")) {
this._subscribeDescriptions();
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("triggers");
@@ -45,7 +45,6 @@ export class HaZoneTrigger extends LitElement {
.value=${entity_id ? ensureArray(entity_id) : []}
.disabled=${this.disabled}
@value-changed=${this._entityPicked}
.hass=${this.hass}
.entityFilter=${zoneAndLocationFilter}
></ha-entities-picker>
<ha-entity-picker
@@ -55,7 +54,6 @@ export class HaZoneTrigger extends LitElement {
.value=${zone}
.disabled=${this.disabled}
@value-changed=${this._zonePicked}
.hass=${this.hass}
.includeDomains=${includeDomains}
></ha-entity-picker>
@@ -508,7 +508,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
</div>
<ha-filter-states
.hass=${this.hass}
.label=${this.hass.localize("ui.panel.config.backup.backup_type")}
.value=${this._filters[TYPE_FILTER]}
.states=${this._states(this.hass.localize, isHassio)}
@@ -517,7 +516,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
></ha-filter-states>
<ha-filter-states
.hass=${this.hass}
.label=${this.hass.localize("ui.panel.config.backup.locations")}
.value=${this._filters[LOCATIONS_FILTER]}
.states=${this._locations(
@@ -60,7 +60,7 @@ export class DialogManageCloudhook extends LitElement {
>
<div>
<p>
${!cloudhook.managed
${cloudhook.managed
? html`
${this.hass!.localize(
"ui.panel.config.cloud.dialog_cloudhook.managed_by_integration"
-2
View File
@@ -99,7 +99,6 @@ export class AITaskPref extends LitElement {
</span>
<ha-entity-picker
data-name="gen_data_entity_id"
.hass=${this.hass}
.disabled=${this._prefs === undefined &&
isComponentLoaded(this.hass.config, "ai_task")}
.value=${this._gen_data_entity_id ||
@@ -119,7 +118,6 @@ export class AITaskPref extends LitElement {
</span>
<ha-entity-picker
data-name="gen_image_entity_id"
.hass=${this.hass}
.disabled=${this._prefs === undefined &&
isComponentLoaded(this.hass.config, "ai_task")}
.value=${this._gen_image_entity_id ||
@@ -50,7 +50,6 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
>
<div class="card-content">
<ha-entity-picker
.hass=${this.hass}
.helper=${this.hass.localize(
"ui.panel.config.developer-tools.tabs.debug.entity_diagnostic.description"
)}
@@ -2,6 +2,7 @@ import { mdiDotsVertical } from "@mdi/js";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
import { navigate } from "../../../common/navigate";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
@@ -91,7 +92,11 @@ class PanelDeveloperTools extends LitElement {
panel=${tab.panel}
.active=${page === tab.panel}
>
${this.hass.localize(tab.translationKey)}
<a
href="/config/developer-tools/${tab.panel}"
@click=${this._handleTabAnchorClick}
>${this.hass.localize(tab.translationKey)}</a
>
</ha-tab-group-tab>
`
)}
@@ -105,6 +110,14 @@ class PanelDeveloperTools extends LitElement {
`;
}
private _handleTabAnchorClick(ev: MouseEvent) {
ev.stopPropagation();
const href = isNavigationClick(ev);
if (href) {
navigate(href);
}
}
private _handlePageSelected(ev: CustomEvent<{ name: string }>) {
const newPage = ev.detail.name;
if (!newPage) {
@@ -142,6 +155,16 @@ class PanelDeveloperTools extends LitElement {
--ha-tab-indicator-color: var(--app-header-text-color, white);
--ha-tab-track-color: transparent;
}
ha-tab-group-tab::part(base) {
padding: 0;
}
ha-tab-group-tab a {
color: inherit;
text-decoration: none;
display: flex;
align-items: center;
padding: 1em 1.5em;
}
`;
}
@@ -220,7 +220,6 @@ class HaPanelDevState extends LitElement {
<div class="inputs">
<ha-entity-picker
autofocus
.hass=${this.hass}
.value=${this._entityId}
@value-changed=${this._entityIdChanged}
show-entity-id
@@ -1,5 +1,5 @@
import { css, html, LitElement, nothing } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { consume, type ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import {
@@ -12,27 +12,10 @@ import {
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-adaptive-dialog";
import "../../../../components/ha-spinner";
import type { AutomationConfig } from "../../../../data/automation";
import { showAutomationEditor } from "../../../../data/automation";
import {
apiContext,
internationalizationContext,
statesContext,
} from "../../../../data/context";
import type {
DeviceAction,
DeviceCondition,
DeviceTrigger,
} from "../../../../data/device/device_automation";
import {
fetchDeviceActions,
fetchDeviceConditions,
fetchDeviceTriggers,
sortDeviceAutomations,
} from "../../../../data/device/device_automation";
import type { ScriptConfig } from "../../../../data/script";
import { showScriptEditor } from "../../../../data/script";
import { showSceneEditor } from "../../../../data/scene";
import "../../../../dialogs/add-to/ha-add-to-action-list";
import type {
@@ -48,21 +31,11 @@ import {
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { DeviceAddToDialogParams } from "./show-dialog-device-add-to";
type DeviceLegacyAddToActionType =
| "trigger"
| "condition"
| "automation_action"
| "script_action";
type DeviceAddToAction =
| (AddToActionListItem & {
kind: "add-to";
key: AddToAutomationScriptActionKey;
})
| (AddToActionListItem & {
kind: "legacy";
legacyType: DeviceLegacyAddToActionType;
})
| (AddToActionListItem & { kind: "scene" });
@customElement("dialog-device-add-to")
@@ -75,76 +48,25 @@ export class DialogDeviceAddTo extends LitElement {
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state() private _params?: DeviceAddToDialogParams;
@state() private _open = false;
@state() private _triggers?: DeviceTrigger[];
@state() private _conditions?: DeviceCondition[];
@state() private _actions?: DeviceAction[];
public showDialog(params: DeviceAddToDialogParams): void {
this._params = params;
this._open = true;
// When new_triggers_conditions labs feature is promoted, this whole check can be removed.
if (!params.newTriggersConditions && this._api) {
this._fetchDeviceAutomations(params);
}
}
public closeDialog(): void {
this._open = false;
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// When new_triggers_conditions labs feature is promoted, this whole check can be removed.
if (
changedProps.has("_api") &&
this._api &&
this._params &&
!this._params.newTriggersConditions &&
!this._triggers
) {
this._fetchDeviceAutomations(this._params);
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
protected firstUpdated() {
this._i18n.loadBackendTranslation("device_automation");
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
private async _fetchDeviceAutomations(
params: DeviceAddToDialogParams
): Promise<void> {
const deviceId = params.device.id;
const [triggers, conditions, actions] = await Promise.all([
fetchDeviceTriggers(this._api.callWS, deviceId),
fetchDeviceConditions(this._api.callWS, deviceId),
fetchDeviceActions(this._api.callWS, deviceId),
]);
this._triggers = triggers.sort(sortDeviceAutomations);
this._conditions = conditions.sort(sortDeviceAutomations);
this._actions = actions.sort(sortDeviceAutomations);
}
private _dialogClosed(): void {
this._params = undefined;
this._triggers = undefined;
this._conditions = undefined;
this._actions = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -168,14 +90,12 @@ export class DialogDeviceAddTo extends LitElement {
)}
@closed=${this._dialogClosed}
>
${this._params.newTriggersConditions
? this._renderNewOptions()
: this._renderLegacyOptions()}
${this._renderOptions()}
</ha-adaptive-dialog>
`;
}
private _renderNewOptions() {
private _renderOptions() {
if (!this._params) {
return nothing;
}
@@ -238,112 +158,6 @@ export class DialogDeviceAddTo extends LitElement {
`;
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
private _renderLegacyOptions() {
if (!this._triggers && !this._conditions && !this._actions) {
return html`
<div class="loading">
<ha-spinner></ha-spinner>
</div>
`;
}
if (!this._params) {
return nothing;
}
const hasTriggers = Boolean(this._triggers?.length);
const hasConditions = Boolean(this._conditions?.length);
const hasActions = Boolean(this._actions?.length);
const hasScenes = Boolean(this._params.entityIds.length);
if (!hasTriggers && !hasConditions && !hasActions && !hasScenes) {
return html`
<div class="empty">
${this._i18n.localize(
"ui.panel.config.devices.automation.no_device_automations"
)}
</div>
`;
}
const automationActions: DeviceAddToAction[] = [];
if (hasTriggers) {
automationActions.push({
kind: "legacy",
legacyType: "trigger",
iconPath: mdiRobotOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_trigger"
),
});
}
if (hasConditions) {
automationActions.push({
kind: "legacy",
legacyType: "condition",
iconPath: mdiPlaylistCheck,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_condition"
),
});
}
if (hasActions) {
automationActions.push({
kind: "legacy",
legacyType: "automation_action",
iconPath: mdiPlayCircleOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_action"
),
});
}
const scriptActions: DeviceAddToAction[] = hasActions
? [
{
kind: "legacy",
legacyType: "script_action",
iconPath: mdiScriptTextOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.script_action"
),
},
]
: [];
const sections: AddToActionListSection<DeviceAddToAction>[] = [
{
title: this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
),
actions: automationActions,
empty: automationActions.length
? undefined
: this._i18n.localize(
"ui.panel.config.devices.automation.no_automations"
),
},
{
title: this._i18n.localize(
"ui.panel.config.devices.script.scripts_heading"
),
actions: scriptActions,
empty: scriptActions.length
? undefined
: this._i18n.localize("ui.panel.config.devices.script.no_scripts"),
},
];
this._addSceneSection(sections);
return html`
<ha-add-to-action-list
.sections=${sections}
@add-to-list-action-selected=${this._handleActionSelected}
></ha-add-to-action-list>
`;
}
private _addSceneSection(
sections: AddToActionListSection<DeviceAddToAction>[]
): void {
@@ -380,12 +194,7 @@ export class DialogDeviceAddTo extends LitElement {
return;
}
if (action.kind === "add-to") {
this._handleAddToAction(action.key);
return;
}
this._handleLegacyAction(action.legacyType);
this._handleAddToAction(action.key);
}
private _handleAddToAction(key: AddToAutomationScriptActionKey) {
@@ -397,30 +206,6 @@ export class DialogDeviceAddTo extends LitElement {
addToActionHandler(key, { device_id: this._params.device.id });
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
private _handleLegacyAction(type: DeviceLegacyAddToActionType) {
this.closeDialog();
if (type === "script_action") {
const newScript = {} as ScriptConfig;
if (this._actions?.length) {
newScript.sequence = [this._actions[0]];
}
showScriptEditor(newScript, true);
return;
}
const newAutomation = {} as AutomationConfig;
if (type === "trigger" && this._triggers?.length) {
newAutomation.triggers = [this._triggers[0]];
} else if (type === "condition" && this._conditions?.length) {
newAutomation.conditions = [this._conditions[0]];
} else if (type === "automation_action" && this._actions?.length) {
newAutomation.actions = [this._actions[0]];
}
showAutomationEditor(newAutomation, true);
}
private _handleCreateScene() {
if (!this._params) {
return;
@@ -439,12 +224,6 @@ export class DialogDeviceAddTo extends LitElement {
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
.loading,
.empty {
padding: var(--ha-space-4);
text-align: center;
}
`,
];
}
@@ -3,7 +3,6 @@ import type { DeviceRegistryEntry } from "../../../../data/device/device_registr
export interface DeviceAddToDialogParams {
device: DeviceRegistryEntry;
newTriggersConditions: boolean;
entityIds: string[];
canCreateScene: boolean;
}
@@ -26,10 +26,7 @@ import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { ASSIST_ENTITIES, SENSOR_ENTITIES } from "../../../common/const";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
} from "../../../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
@@ -65,13 +62,12 @@ import {
disableConfigEntry,
sortConfigEntries,
} from "../../../data/config_entries";
import { fullEntitiesContext } from "../../../data/context";
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import {
removeConfigEntryFromDevice,
updateDeviceRegistryEntry,
} from "../../../data/device/device_registry";
import { subscribeLabFeature } from "../../../data/labs";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import {
fetchDiagnosticHandler,
@@ -207,10 +203,6 @@ export class HaConfigDevicePage extends LitElement {
private _deviceAlertsActionsTimeout?: number;
@state() private _newTriggersConditions = false;
private _unsubLabFeature?: (() => void) | undefined;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -380,15 +372,13 @@ export class HaConfigDevicePage extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
loadDeviceRegistryDetailDialog();
this._subscribeLabFeature();
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("deviceId")) {
this._findRelated();
// Broadcast device context for quick bar
fireEvent(this, "hass-related-context", {
fireRelatedContext(this, {
itemType: "device",
itemId: this.deviceId,
});
@@ -398,7 +388,6 @@ export class HaConfigDevicePage extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this._deviceAlertsActionsTimeout);
this._unsubLabFeature?.();
}
protected render() {
@@ -925,7 +914,7 @@ export class HaConfigDevicePage extends LitElement {
.time=${this._logbookTime}
.entityIds=${this._entityIds(entities)}
.deviceIds=${this._deviceIdInList(this.deviceId)}
.scope=${"device"}
name-detail="entity"
virtualize
narrow
no-icon
@@ -1408,7 +1397,6 @@ export class HaConfigDevicePage extends LitElement {
);
showDeviceAddToDialog(this, {
device,
newTriggersConditions: this._newTriggersConditions,
entityIds: sceneEntityIds,
canCreateScene:
isComponentLoaded(this.hass.config, "scene") &&
@@ -1416,23 +1404,6 @@ export class HaConfigDevicePage extends LitElement {
});
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
private _subscribeLabFeature() {
if (!isComponentLoaded(this.hass.config, "automation")) {
return;
}
subscribeLabFeature(
this.hass.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersConditions = feature.enabled;
}
).then((unsub) => {
this._unsubLabFeature = unsub;
});
}
private _renderIntegrationInfo(
device: DeviceRegistryEntry,
integrations: ConfigEntry[],
@@ -859,7 +859,6 @@ export class HaConfigDeviceDashboard extends LitElement {
@expanded-changed=${this._filterExpanded}
></ha-filter-integrations>
<ha-filter-states
.hass=${this.hass}
.value=${this._filters["ha-filter-states"]?.value}
.states=${this._states(this.hass.localize)}
.label=${this.hass.localize("ui.panel.config.devices.picker.state")}
@@ -269,7 +269,6 @@ export class DialogEnergyGasSettings
: this._costs === "entity"
? html`<ha-entity-picker
class="price-options"
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${this.hass.localize(
@@ -328,7 +328,6 @@ export class DialogEnergyGridSettings
${this._importCostType === "entity"
? html`
<ha-entity-picker
.hass=${this.hass}
.value=${this._source.entity_energy_price}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_entity_label"
@@ -416,7 +415,6 @@ export class DialogEnergyGridSettings
${this._exportCostType === "entity"
? html`
<ha-entity-picker
.hass=${this.hass}
.value=${this._source.entity_energy_price_export}
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_entity_label"
@@ -230,7 +230,6 @@ export class DialogEnergyWaterSettings
: this._costs === "entity"
? html`<ha-entity-picker
class="price-options"
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${this.hass.localize(
@@ -747,7 +747,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
SCANNER_SOURCE_TYPES.includes(stateObj?.attributes?.source_type)
? html`
<ha-entity-picker
.hass=${this.hass}
.value=${this._associatedZone}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.associated_zone"
@@ -1019,7 +1019,6 @@ export class HaConfigEntities extends LitElement {
@expanded-changed=${this._filterExpanded}
></ha-filter-integrations>
<ha-filter-states
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.entities.picker.headers.status"
)}
@@ -13,6 +13,8 @@ import {
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import { navigate } from "../../../../../common/navigate";
import { animationStyles } from "../../../../../resources/theme/animations.globals";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
@@ -21,6 +23,7 @@ import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
@@ -66,20 +69,46 @@ class ZHAConfigDashboard extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
if (this.hass) {
this.hass.loadBackendTranslation("config_panel", "zha", false);
this._fetchConfigEntry();
this._fetchConfiguration();
this._fetchDevicesAndGroups();
if (!this.hass) {
return;
}
if (!isComponentLoaded(this.hass.config, "zha")) {
navigate("/config/integrations", { replace: true });
return;
}
this.hass.loadBackendTranslation("config_panel", "zha", false);
this._load();
}
private async _load(): Promise<void> {
await this._fetchConfigEntry();
if (!this._configEntry) {
return;
}
this._fetchConfiguration();
this._fetchDevicesAndGroups();
}
protected render(): TemplateResult {
const devices = this._configEntry
? Object.values(this.hass.devices).filter((device) =>
device.config_entries.includes(this._configEntry!.entry_id)
)
: [];
if (!this._configEntry) {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.zha.network.caption")}
back-path="/config"
>
<div class="loading">
<ha-spinner></ha-spinner>
</div>
</hass-subpage>
`;
}
const configEntry = this._configEntry;
const devices = Object.values(this.hass.devices).filter((device) =>
device.config_entries.includes(configEntry.entry_id)
);
const deviceCount = devices.length;
let entityCount = 0;
@@ -463,6 +492,12 @@ class ZHAConfigDashboard extends LitElement {
margin-top: var(--ha-space-6);
}
.loading {
display: flex;
justify-content: center;
padding: var(--ha-space-12);
}
ha-md-list {
background: none;
padding: 0;
@@ -252,7 +252,6 @@ class DialogZWaveJSAddNode extends LitElement {
return html`
<div>
<ha-qr-scanner
.hass=${this.hass}
@qr-code-scanned=${this._qrCodeScanned}
@qr-code-closed=${this.closeDialog}
@qr-code-more-options=${this._qrScanShowMoreOptions}

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