Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Bottein b24eb2c916 Fix google assistant alias count 2026-06-23 18:23:48 +02:00
Paul Bottein 071f8e96ae Split google assistant, alexa and assist settings 2026-06-23 18:09:47 +02:00
138 changed files with 2467 additions and 4871 deletions
-244
View File
@@ -1,244 +0,0 @@
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: ${{ !cancelled() }}
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,16 +54,8 @@ 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.18.0
24.17.0
@@ -0,0 +1,18 @@
diff --git a/lib/cook-raw-quasi.js b/lib/cook-raw-quasi.js
index 3ea8fa7be8e357c1066d7417caeeecd841415208..6bf04ab0bed8897b5ff2898ca835867aec5cee6a 100644
--- a/lib/cook-raw-quasi.js
+++ b/lib/cook-raw-quasi.js
@@ -1,10 +1,11 @@
'use strict';
-function cookRawQuasi({transform}, raw) {
+function cookRawQuasi({transformSync}, raw) {
// This nasty hack is needed until https://github.com/babel/babel/issues/9242 is resolved.
const args = {raw};
- transform('cooked`' + args.raw + '`', {
+ // Babel 8 removed synchronous `transform`; use `transformSync` instead.
+ transformSync('cooked`' + args.raw + '`', {
babelrc: false,
configFile: false,
plugins: [
+33 -36
View File
@@ -1,3 +1,4 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -83,7 +84,12 @@ module.exports.swcOptions = () => ({
},
});
module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
module.exports.babelOptions = ({
latestBuild,
isProdBuild,
isTestBuild,
sw,
}) => ({
babelrc: false,
compact: false,
assumptions: {
@@ -96,22 +102,13 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
[
"@babel/preset-env",
{
useBuiltIns: "usage",
corejs: dependencies["core-js"],
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"),
{
@@ -119,14 +116,32 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
ignoreModuleNotFound: true,
},
],
// 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`.
// 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
[
"@babel/plugin-transform-runtime",
{ version: dependencies["@babel/runtime"], moduleName: "@babel/runtime" },
{ version: dependencies["@babel/runtime"] },
],
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-private-methods",
@@ -305,22 +320,4 @@ 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,13 +1,9 @@
// @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,3 +1,4 @@
/* 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,10 +45,3 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
-41
View File
@@ -1,41 +0,0 @@
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"
)
);
+1 -21
View File
@@ -1,3 +1,4 @@
/* global process */
// Tasks to generate entry HTML
import {
@@ -267,24 +268,3 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
-20
View File
@@ -201,23 +201,3 @@ 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,7 +4,6 @@ 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";
+29 -69
View File
@@ -1,6 +1,6 @@
// Gulp task to generate third-party license notices.
import { readFile, access, readdir } from "fs/promises";
import { readFile, access } from "fs/promises";
import { generateLicenseFile } from "generate-license-file";
import gulp from "gulp";
import path from "path";
@@ -11,98 +11,58 @@ 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.join(NODE_MODULES, "echarts/NOTICE")];
const NOTICE_FILES = [
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
];
// 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).
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers 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 pinned version is no longer installed.
// that the new version's license still matches. The build will fail
// if the installed version does not match the pinned version.
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",
licenseFile: "license-mit",
licensePath: path.resolve(
paths.root_dir,
"node_modules/type-fest/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, licenseFile } of LICENSE_OVERRIDES) {
// eslint-disable-next-line no-await-in-loop
const packageDir = await findPackageDir(packageName, version);
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
const pkgJsonPath = path.resolve(
paths.root_dir,
`node_modules/${packageName}/package.json`
);
if (!packageDir) {
let packageJSON;
try {
// eslint-disable-next-line no-await-in-loop
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
} catch {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but that version is not installed. ` +
`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}. ` +
`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,7 +14,6 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -232,22 +231,3 @@ 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(),
})
)
);
+4 -10
View File
@@ -48,12 +48,6 @@ 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`));
@@ -65,16 +59,16 @@ for (const buildType of ["Modern", "Legacy"]) {
console.log(detailsClose);
// Manually log the Core-JS polyfills using the same technique
if (corejsOpts) {
if (presetEnvOpts.useBuiltIns) {
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
const targets = compilationTargets.default(babelOpts?.targets, {
browserslistEnv,
});
const polyfillList = coreJSCompat({ targets }).list.filter(
polyfillFilter(
corejsOpts.method,
corejsOpts.proposals,
corejsOpts.shippedProposals
`${presetEnvOpts.useBuiltIns}-global`,
presetEnvOpts?.corejs?.proposals,
presetEnvOpts?.shippedProposals
)
);
console.log(
@@ -1,8 +0,0 @@
/* global module */
module.exports = function litDisableDevModeLoader(source) {
return source.replace(
/\b(const|let|var) DEV_MODE = true;/g,
"$1 DEV_MODE = false;"
);
};
@@ -1,63 +0,0 @@
/* 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,15 +50,4 @@ 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"
),
};
+19 -88
View File
@@ -1,3 +1,4 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -47,12 +48,6 @@ 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",
@@ -72,42 +67,25 @@ const createRspackConfig = ({
{
test: /\.m?js$|\.ts$/,
exclude: /node_modules[\\/]core-js/,
use: (info) =>
[
{
loader: "babel-loader",
options: {
...bundle.babelOptions({
latestBuild,
isTestBuild,
sw: info.issuerLayer === "sw",
}),
cacheDirectory: !isProdBuild,
cacheCompression: false,
},
use: (info) => [
{
loader: "babel-loader",
options: {
...bundle.babelOptions({
latestBuild,
isProdBuild,
isTestBuild,
sw: info.issuerLayer === "sw",
}),
cacheDirectory: !isProdBuild,
cacheCompression: false,
},
// 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),
},
{
loader: "builtin:swc-loader",
options: bundle.swcOptions(),
},
],
resolve: {
fullySpecified: false,
},
@@ -154,47 +132,6 @@ 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 })
),
@@ -401,11 +338,6 @@ 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,
@@ -413,5 +345,4 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
+1 -3
View File
@@ -65,9 +65,7 @@ export class HaDemo extends HomeAssistantAppEl {
this._updateHass(hassUpdate),
};
// `false` for contexts: HomeAssistantAppEl already provides them via
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
const hass = provideHass(this, initial, true, false);
const hass = provideHass(this, initial, true);
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
-21
View File
@@ -1,21 +0,0 @@
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
@@ -1,5 +0,0 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
-6
View File
@@ -234,12 +234,6 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
-91
View File
@@ -1,4 +1,3 @@
import { ContextProvider } from "@lit/context";
import { mdiCog, mdiMenu } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -20,22 +19,6 @@ import "../../src/components/ha-svg-icon";
import "../../src/components/ha-top-app-bar-fixed";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import {
apiContext,
areasContext,
configContext,
connectionContext,
devicesContext,
entitiesContext,
floorsContext,
formattersContext,
internationalizationContext,
registriesContext,
servicesContext,
statesContext,
uiContext,
} from "../../src/data/context";
import { updateHassGroups } from "../../src/data/context/updateContext";
import type { HomeAssistant, ThemeSettings } from "../../src/types";
import { PAGES, SIDEBAR } from "../build/import-pages";
import {
@@ -130,65 +113,6 @@ class HaGallery extends LitElement {
@state() private _drawerOpen = !this._narrow;
// Fallback Lit context providers for the whole gallery. The real app's root
// element provides these via `contextMixin`; here we mirror that so demos
// which render context-consuming components without setting up their own hass
// (e.g. bare component demos) still resolve `localize`, formatters, config,
// etc. instead of throwing during init. Demos that call `provideHass`
// register their own providers closer in the tree, which take precedence.
private _contextProviders = {
registries: new ContextProvider(this, { context: registriesContext }),
internationalization: new ContextProvider(this, {
context: internationalizationContext,
}),
api: new ContextProvider(this, { context: apiContext }),
connection: new ContextProvider(this, { context: connectionContext }),
ui: new ContextProvider(this, { context: uiContext }),
config: new ContextProvider(this, { context: configContext }),
formatters: new ContextProvider(this, { context: formattersContext }),
};
// The individual (non-grouped) contexts contextMixin also provides. Components
// such as ha-area-picker / ha-entity-picker consume these directly, so the
// fallback must cover them too.
private _singleContextProviders = {
states: new ContextProvider(this, { context: statesContext }),
services: new ContextProvider(this, { context: servicesContext }),
entities: new ContextProvider(this, { context: entitiesContext }),
devices: new ContextProvider(this, { context: devicesContext }),
areas: new ContextProvider(this, { context: areasContext }),
floors: new ContextProvider(this, { context: floorsContext }),
};
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Refresh the fallback contexts before each render so theme/page changes in
// the gallery hass propagate to consuming components.
const hass = this._galleryHass;
(
Object.keys(
this._contextProviders
) as (keyof typeof this._contextProviders)[]
).forEach((group) => {
const provider = this._contextProviders[group];
provider.setValue(
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
hass,
provider.value
)
);
});
(
Object.keys(
this._singleContextProviders
) as (keyof typeof this._singleContextProviders)[]
).forEach((key) => {
(this._singleContextProviders[key] as ContextProvider<any>).setValue(
hass[key]
);
});
}
render() {
const isSettingsPage = this._page === SETTINGS_PAGE;
const page = isSettingsPage ? undefined : PAGES[this._page];
@@ -652,21 +576,6 @@ class HaGallery extends LitElement {
callWS: async () => undefined,
fetchWithAuth: async () => new Response(),
sendWS: () => undefined,
formatEntityState: (stateObj, stateValue) =>
(stateValue != null ? stateValue : stateObj.state) ?? "",
formatEntityStateToParts: (stateObj, stateValue) => [
{
type: "value",
value: (stateValue != null ? stateValue : stateObj.state) ?? "",
},
],
formatEntityAttributeName: (_stateObj, attribute) => attribute,
formatEntityAttributeValue: (stateObj, attribute, value) =>
value != null ? value : (stateObj.attributes[attribute] ?? ""),
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
} as unknown as HomeAssistant;
}
+28 -1
View File
@@ -1,4 +1,5 @@
import type { TemplateResult } from "lit";
import { ContextProvider } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
@@ -14,6 +15,11 @@ import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import {
configContext,
internationalizationContext,
} from "../../../../src/data/context";
import { updateHassGroups } from "../../../../src/data/context/updateContext";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
@@ -522,6 +528,17 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
private data = SCHEMAS.map(() => ({}));
// The date/datetime selectors and the date-picker dialog consume these
// contexts (provided by the root element in the real app). Provide them here
// so they work in the gallery.
private _i18nProvider = new ContextProvider(this, {
context: internationalizationContext,
});
private _configProvider = new ContextProvider(this, {
context: configContext,
});
constructor() {
super();
const hass = provideHass(this);
@@ -543,6 +560,16 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
el.hass = this.hass;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("hass") && this.hass) {
this._i18nProvider.setValue(
updateHassGroups.internationalization(this.hass)
);
this._configProvider.setValue(updateHassGroups.config(this.hass));
}
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
+1 -4
View File
@@ -1,6 +1,5 @@
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;
@@ -18,9 +17,7 @@ export class DemoMiscBoxShadow extends LitElement {
(size) => html`
<div
class="box"
style=${styleMap({
boxShadow: `var(--ha-box-shadow-${size})`,
})}
style="box-shadow: var(--ha-box-shadow-${size})"
>
${size}
</div>
+7 -16
View File
@@ -22,12 +22,7 @@
"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: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"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
@@ -132,11 +127,10 @@
"xss": "1.0.15"
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.1",
"@babel/core": "8.0.0",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.1",
"@babel/preset-env": "8.0.2",
"@babel/plugin-transform-runtime": "8.0.0",
"@babel/preset-env": "8.0.0",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.62.0",
@@ -144,11 +138,9 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.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",
@@ -165,7 +157,7 @@
"@types/tar": "7.0.87",
"@vitest/coverage-v8": "4.1.9",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"babel-plugin-template-html-minifier": "patch:babel-plugin-template-html-minifier@npm%3A4.1.0#~/.yarn/patches/babel-plugin-template-html-minifier-npm-4.1.0-9a3c00055a.patch",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.5.0",
@@ -190,12 +182,11 @@
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.0.8",
"lint-staged": "17.0.7",
"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",
@@ -225,6 +216,6 @@
},
"packageManager": "yarn@4.17.0",
"volta": {
"node": "24.18.0"
"node": "24.17.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260624.0"
version = "20260527.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -1,13 +1,4 @@
import type { HaDurationData } from "../../components/ha-duration-input";
export default function durationToSeconds(duration: string): number {
const parts = duration.split(":").map(Number);
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
export const durationDataToSeconds = (duration: HaDurationData): number =>
(duration.days || 0) * 86400 +
(duration.hours || 0) * 3600 +
(duration.minutes || 0) * 60 +
(duration.seconds || 0) +
(duration.milliseconds || 0) / 1000;
@@ -125,15 +125,7 @@ export interface EntityPickerDisplay {
}
export const computeEntityPickerDisplay = (
hass: Pick<
HomeAssistant,
| "entities"
| "devices"
| "areas"
| "floors"
| "language"
| "translationMetadata"
>,
hass: HomeAssistant,
stateObj: HassEntity
): EntityPickerDisplay => {
const [entityName, deviceName, areaName] = computeEntityNameList(
@@ -1463,12 +1463,6 @@ 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;
}
+9 -1
View File
@@ -9,13 +9,15 @@ import {
} from "../../common/dom/fire_event";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { ValueChangedEvent } from "../../types";
import type { HomeAssistant, 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;
@@ -85,6 +87,10 @@ 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}
@@ -99,6 +105,7 @@ class HaEntitiesPicker extends LitElement {
<div class="entity">
<ha-entity-picker
.curValue=${entityId}
.hass=${this.hass}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeEntities=${this.includeEntities}
@@ -126,6 +133,7 @@ class HaEntitiesPicker extends LitElement {
</ha-sortable>
<div>
<ha-entity-picker
.hass=${this.hass}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeEntities=${this.includeEntities}
+40 -73
View File
@@ -1,5 +1,5 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { consume } 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";
@@ -8,14 +8,7 @@ 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 { relatedContext } from "../../data/context";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import {
entityComboBoxKeys,
@@ -45,21 +38,7 @@ const CREATE_ID = "___create-new-entity___";
@customElement("ha-entity-picker")
export class HaEntityPicker extends LitElement {
@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>;
@property({ attribute: false }) public hass!: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -171,11 +150,12 @@ export class HaEntityPicker extends LitElement {
);
}
protected willUpdate(changedProperties: PropertyValues) {
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
this._pendingEntityId &&
changedProperties.has("_states") &&
this._states[this._pendingEntityId]
changedProperties.has("hass") &&
this.hass.states !== changedProperties.get("hass")?.states &&
this.hass.states[this._pendingEntityId]
) {
this._setValue(this._pendingEntityId);
this._pendingEntityId = undefined;
@@ -185,7 +165,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._i18n.loadBackendTranslation("title");
this.hass.loadBackendTranslation("title");
}
private _findExtraOption(value: string | undefined) {
@@ -196,7 +176,7 @@ export class HaEntityPicker extends LitElement {
private _renderExtraOptionStart(extraOption: EntitySelectorExtraOption) {
const stateObj = extraOption.entity_id
? this._states[extraOption.entity_id]
? this.hass.states[extraOption.entity_id]
: undefined;
if (stateObj) {
return html`
@@ -232,7 +212,7 @@ export class HaEntityPicker extends LitElement {
`;
}
const stateObj = this._states[entityId];
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return html`
@@ -246,11 +226,7 @@ export class HaEntityPicker extends LitElement {
}
const { primary, secondary } = computeEntityPickerDisplay(
{
...this._registries,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
this.hass,
stateObj
);
@@ -262,7 +238,7 @@ export class HaEntityPicker extends LitElement {
};
private get _showEntityId() {
return this.showEntityId || this._config.userData?.showEntityIdPicker;
return this.showEntityId || this.hass.userData?.showEntityIdPicker;
}
private _rowRenderer: RenderItemFunction<EntityComboBoxItem> = (
@@ -310,14 +286,17 @@ export class HaEntityPicker extends LitElement {
};
private _getAdditionalItems = () =>
this._getCreateItems(this._i18n.localize, this.createDomains);
this._getCreateItems(this.hass.localize, this.createDomains);
private _getCreateItems = memoizeOne(
(localize: LocalizeFunc, createDomains: this["createDomains"]) => {
(
localize: this["hass"]["localize"],
createDomains: this["createDomains"]
) => {
if (!createDomains?.length) {
return [];
}
this._i18n.loadFragmentTranslation("config");
this.hass.loadFragmentTranslation("config");
return createDomains.map((domain) => {
const primary = localize(
"ui.components.entity.entity-picker.create_helper",
@@ -342,9 +321,7 @@ export class HaEntityPicker extends LitElement {
private _getEntitiesMemoized = memoizeOne(
(
states: ContextType<typeof statesContext>,
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
hass: HomeAssistant,
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
@@ -354,25 +331,16 @@ export class HaEntityPicker extends LitElement {
excludeEntities?: string[],
value?: string
) =>
getEntities(
{
states,
...registries,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
localize: i18n.localize,
},
{
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
}
)
getEntities(hass, {
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
})
);
private _sortByRelatedContext = memoizeOne(
@@ -391,9 +359,7 @@ export class HaEntityPicker extends LitElement {
private _getItems = () => {
const entityItems = this._getEntitiesMemoized(
this._states,
this._registries,
this._i18n,
this.hass,
this.includeDomains,
this.excludeDomains,
this.entityFilter,
@@ -407,15 +373,15 @@ export class HaEntityPicker extends LitElement {
? this._sortByRelatedContext(
entityItems,
this._relatedIdSets!,
this._registries.entities,
this._registries.devices,
this._i18n.locale.language
this.hass.entities,
this.hass.devices,
this.hass.locale.language
)
: entityItems;
if (this.extraOptions?.length) {
const resolvedExtras = this.extraOptions.map((opt) => ({
...opt,
stateObj: opt.entity_id ? this._states[opt.entity_id] : undefined,
stateObj: opt.entity_id ? this.hass.states[opt.entity_id] : undefined,
}));
return [...resolvedExtras, ...sortedItems];
}
@@ -429,10 +395,11 @@ export class HaEntityPicker extends LitElement {
protected render() {
const placeholder =
this.placeholder ??
this._i18n.localize("ui.components.entity.entity-picker.placeholder");
this.hass.localize("ui.components.entity.entity-picker.placeholder");
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
@@ -454,9 +421,9 @@ export class HaEntityPicker extends LitElement {
use-top-label
.addButtonLabel=${this.addButton
? (this.addButtonLabel ??
this._i18n.localize("ui.components.entity.entity-picker.add"))
this.hass.localize("ui.components.entity.entity-picker.add"))
: undefined}
.unknownItemText=${this._i18n.localize(
.unknownItemText=${this.hass.localize(
"ui.components.entity.entity-picker.unknown"
)}
@value-changed=${this._valueChanged}
@@ -509,7 +476,7 @@ export class HaEntityPicker extends LitElement {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) {
if (this._states[item.entityId]) {
if (this.hass.states[item.entityId]) {
this._setValue(item.entityId);
} else {
this._pendingEntityId = item.entityId;
@@ -535,7 +502,7 @@ export class HaEntityPicker extends LitElement {
}
private _notFoundLabel = (search: string) =>
this._i18n.localize("ui.components.entity.entity-picker.no_match", {
this.hass.localize("ui.components.entity.entity-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
@@ -63,6 +63,7 @@ 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}
@@ -79,6 +80,7 @@ export class HaEntitySelector extends LitElement {
return html`
<ha-entities-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.placeholder=${this.placeholder}
@@ -91,6 +91,7 @@ export class HaMediaSelector extends LitElement {
? nothing
: html`
<ha-entity-picker
.hass=${this.hass}
.value=${entityId}
.label=${this.label ||
this.hass.localize(
+1
View File
@@ -558,6 +558,7 @@ 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 -5
View File
@@ -41,11 +41,7 @@ export class HaInputSearch extends HaInput {
...HaInput.styles,
css`
:host([appearance="outlined"]) wa-input.no-label::part(base) {
height: var(--ha-input-search-height, 40px);
border-radius: var(
--ha-input-search-border-radius,
var(--ha-border-radius-md)
);
height: 40px;
}
`,
];
+5 -1
View File
@@ -6,11 +6,15 @@ export interface AlexaEntity {
interfaces: string[];
}
export interface AlexaEntityConfig {
name?: string | null;
}
export const fetchCloudAlexaEntities = (hass: HomeAssistant) =>
hass.callWS<AlexaEntity[]>({ type: "cloud/alexa/entities" });
export const fetchCloudAlexaEntity = (hass: HomeAssistant, entity_id: string) =>
hass.callWS<AlexaEntity>({
hass.callWS<AlexaEntityConfig>({
type: "cloud/alexa/entities/get",
entity_id,
});
+1 -8
View File
@@ -331,14 +331,7 @@ export interface AutomationElementGroupCollection {
export type AutomationElementGroup = Record<
string,
{
icon?: string;
members?: AutomationElementGroup;
// Backend element domains (e.g. "calendar", "sun") whose triggers/conditions
// are bundled into this group instead of appearing as their own dynamic
// domain group.
domains?: string[];
}
{ icon?: string; members?: AutomationElementGroup }
>;
export type LegacyCondition =
+13 -2
View File
@@ -172,12 +172,23 @@ export const removeCloudData = (hass: HomeAssistant) =>
export const updateCloudGoogleEntityConfig = (
hass: HomeAssistant,
entity_id: string,
disable_2fa: boolean
values: { disable_2fa?: boolean; name?: string | null; aliases?: string[] }
) =>
hass.callWS({
type: "cloud/google_assistant/entities/update",
entity_id,
disable_2fa,
...values,
});
export const updateCloudAlexaEntityConfig = (
hass: HomeAssistant,
entity_id: string,
name: string | null
) =>
hass.callWS({
type: "cloud/alexa/entities/update",
entity_id,
name,
});
export const cloudSyncGoogleAssistant = (hass: HomeAssistant) =>
+7 -12
View File
@@ -1,7 +1,7 @@
import { mdiClockOutline, mdiShape, mdiWeatherSunny } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { mdiMapClock, mdiShape } from "@mdi/js";
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";
@@ -9,14 +9,9 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
dynamicGroups: {},
time: {
icon: mdiClockOutline,
members: { time: {} },
domains: ["calendar", "schedule"],
},
sun: {
icon: mdiWeatherSunny,
domains: ["sun"],
time_location: {
icon: mdiMapClock,
members: { sun: {}, time: {}, zone: {} },
},
helpers: {},
template: {},
@@ -73,10 +68,10 @@ export interface ConditionDescription {
export type ConditionDescriptions = Record<string, ConditionDescription>;
export const subscribeConditions = (
connection: Connection,
hass: HomeAssistant,
callback: (conditions: ConditionDescriptions) => void
) =>
connection.subscribeMessage<ConditionDescriptions>(callback, {
hass.connection.subscribeMessage<ConditionDescriptions>(callback, {
type: "condition_platforms/subscribe",
});
-15
View File
@@ -12,13 +12,11 @@ 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
@@ -133,19 +131,6 @@ 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
+7 -42
View File
@@ -42,12 +42,6 @@ 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>();
@@ -793,30 +787,9 @@ 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;
// 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;
} = {}
options: { prefs?: EnergyPreferences; key?: string } = {}
): EnergyCollection => {
const [key, collectionKey] = convertCollectionKeyToConnection(
hass,
@@ -826,8 +799,6 @@ export const getEnergyDataCollection = (
return (hass.connection as any)[key];
}
const midnightRollover = options.midnightRollover ?? false;
energyCollectionKeys.add(collectionKey);
const collection = getCollection<EnergyData>(
@@ -886,16 +857,12 @@ 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.
// 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.
// Set start to start of today if we have data for today, otherwise yesterday
const preferredPeriod =
(localStorage.getItem(`energy-default-period-${key}`) as DateRange) ||
"today";
const period =
preferredPeriod === "today" && hour === "0" && !midnightRollover
? "yesterday"
: preferredPeriod;
preferredPeriod === "today" && hour === "0" ? "yesterday" : preferredPeriod;
const [start, end] = calcDateRange(hass.locale, hass.config, period);
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
@@ -919,12 +886,10 @@ export const getEnergyDataCollection = (
collection.refresh();
scheduleUpdatePeriod();
},
getNextEnergyPeriodStart(
midnightRollover,
new Date(),
hass.locale,
hass.config
).getTime() - Date.now()
addHours(
calcDate(new Date(), endOfDay, hass.locale, hass.config),
1
).getTime() - Date.now() // Switch to next day an hour after the day changed
);
};
scheduleUpdatePeriod();
+1 -11
View File
@@ -58,17 +58,7 @@ export interface GetEntitiesOptions {
}
export const getEntities = (
hass: Pick<
HomeAssistant,
| "states"
| "entities"
| "devices"
| "areas"
| "floors"
| "language"
| "translationMetadata"
| "localize"
>,
hass: HomeAssistant,
options?: GetEntitiesOptions
): EntityComboBoxItem[] => {
const {
+2
View File
@@ -5,6 +5,8 @@ export interface GoogleEntity {
traits: string[];
might_2fa: boolean;
disable_2fa?: boolean;
name?: string | null;
aliases?: string[] | null;
}
export const fetchCloudGoogleEntities = (hass: HomeAssistant) =>
-12
View File
@@ -1,7 +1,6 @@
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<
@@ -11,13 +10,6 @@ export interface CustomCardSuggestion<
config: T;
}
export interface CustomBadgeSuggestion<
T extends LovelaceBadgeConfig = LovelaceBadgeConfig,
> {
label?: string;
config: T;
}
export interface CustomCardEntry {
type: string;
name?: string;
@@ -36,10 +28,6 @@ export interface CustomBadgeEntry {
description?: string;
preview?: boolean;
documentationURL?: string;
getEntitySuggestion?: (
hass: HomeAssistant,
entityId: string
) => CustomBadgeSuggestion | CustomBadgeSuggestion[] | null;
}
export interface CustomCardFeatureEntry {
-15
View File
@@ -153,21 +153,6 @@ export const getRecorderInfo = (conn: Connection) =>
type: "recorder/info",
});
export type EntityRecordingDisabler = "user";
export interface RecorderEntityOptions {
recording_disabled_by: EntityRecordingDisabler | null;
}
export const getRecorderEntityOptions = (
hass: Pick<HomeAssistant, "callWS">,
entity_id: string
) =>
hass.callWS<RecorderEntityOptions>({
type: "recorder/entity_options/get",
entity_id,
});
export const getStatisticIds = (
hass: Pick<HomeAssistant, "callWS">,
statistic_type?: "mean" | "sum"
+9 -11
View File
@@ -1,8 +1,8 @@
import { mdiClockOutline, mdiShape, mdiWeatherSunny } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { mdiMapClock, mdiShape } from "@mdi/js";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { HomeAssistant } from "../types";
import type {
AutomationElementGroupCollection,
Trigger,
@@ -14,17 +14,15 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
dynamicGroups: {},
time: {
icon: mdiClockOutline,
time_location: {
icon: mdiMapClock,
members: {
calendar: {},
sun: {},
time: {},
time_pattern: {},
zone: {},
},
domains: ["calendar", "schedule"],
},
sun: {
icon: mdiWeatherSunny,
domains: ["sun"],
},
event: {},
geo_location: {},
@@ -75,10 +73,10 @@ export interface TriggerDescription {
export type TriggerDescriptions = Record<string, TriggerDescription>;
export const subscribeTriggers = (
connection: Connection,
hass: HomeAssistant,
callback: (triggers: TriggerDescriptions) => void
) =>
connection.subscribeMessage<TriggerDescriptions>(callback, {
hass.connection.subscribeMessage<TriggerDescriptions>(callback, {
type: "trigger_platforms/subscribe",
});
@@ -0,0 +1,33 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { ExtEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import "../../../../panels/config/voice-assistants/voice-assistant-settings";
import type { HomeAssistant } from "../../../../types";
@customElement("ha-more-info-view-voice-assistant-settings")
class MoreInfoViewVoiceAssistantSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entry!: ExtEntityRegistryEntry;
@property({ attribute: false }) public params?: { assistant: string };
protected render() {
if (!this.params || !this.entry) {
return nothing;
}
return html`<voice-assistant-settings
.hass=${this.hass}
.entityId=${this.entry.entity_id}
.assistant=${this.params.assistant}
.entry=${this.entry}
></voice-assistant-settings>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-view-voice-assistant-settings": MoreInfoViewVoiceAssistantSettings;
}
}
@@ -7,6 +7,7 @@ import type { ExposeEntitySettings } from "../../../../data/expose";
import { voiceAssistants } from "../../../../data/expose";
import "../../../../panels/config/voice-assistants/entity-voice-settings";
import type { HomeAssistant } from "../../../../types";
import { showVoiceAssistantSettingsView } from "./show-view-voice-assistant-settings";
@customElement("ha-more-info-view-voice-assistants")
class MoreInfoViewVoiceAssistants extends LitElement {
@@ -33,9 +34,19 @@ class MoreInfoViewVoiceAssistants extends LitElement {
.entityId=${this.entry.entity_id}
.entry=${this.entry}
.exposed=${this._calculateExposed(this.entry)}
@edit-assistant=${this._editAssistant}
></entity-voice-settings>`;
}
private _editAssistant(ev: CustomEvent) {
const assistant = ev.detail.assistant;
showVoiceAssistantSettingsView(
this,
voiceAssistants[assistant].name,
assistant
);
}
static get styles(): CSSResultGroup {
return [
css`
@@ -0,0 +1,17 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export const loadVoiceAssistantSettingsView = () =>
import("./ha-more-info-view-voice-assistant-settings");
export const showVoiceAssistantSettingsView = (
element: HTMLElement,
title: string,
assistant: string
): void => {
fireEvent(element, "show-child-view", {
viewTag: "ha-more-info-view-voice-assistant-settings",
viewImport: loadVoiceAssistantSettingsView,
viewTitle: title,
viewParams: { assistant },
});
};
+19 -2
View File
@@ -59,12 +59,14 @@ 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,
@@ -124,7 +126,7 @@ const DEFAULT_VIEW: MoreInfoView = "info";
export class MoreInfoDialog extends DirtyStateProviderMixin<
EntitySettingsState | Helper | Record<string, string[]> | null,
"entity-registry" | "helper" | "vacuum-segment-mapping"
>()(ScrollableFadeMixin(LitElement)) {
>()(SubscribeMixin(ScrollableFadeMixin(LitElement))) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@@ -161,6 +163,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _isEscapeEnabled = true;
@state() private _newTriggersAndConditions = false;
protected scrollFadeThreshold = 24;
protected get scrollableElement(): HTMLElement | null {
@@ -256,11 +260,24 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
private _shouldShowAddEntityTo(): boolean {
return (
!!this.hass.user?.is_admin ||
(this._newTriggersAndConditions && !!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
+1 -94
View File
@@ -1,4 +1,3 @@
import { ContextProvider } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import {
applyThemesOnElement,
@@ -7,22 +6,6 @@ import {
import { fireEvent } from "../common/dom/fire_event";
import { computeFormatFunctions } from "../common/translations/entity-state";
import { computeLocalize } from "../common/translations/localize";
import {
apiContext,
areasContext,
configContext,
connectionContext,
devicesContext,
entitiesContext,
floorsContext,
formattersContext,
internationalizationContext,
registriesContext,
servicesContext,
statesContext,
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 {
@@ -102,84 +85,13 @@ export interface MockHomeAssistant extends HomeAssistant {
export const provideHass = (
elements,
overrideData: Partial<HomeAssistant> = {},
setHassProperty = false,
// Provide the grouped Lit contexts (registries, internationalization, api,
// connection, ui, config, formatters) that the real app's root element
// provides via `contextMixin`. On by default so that any standalone hass root
// (e.g. a gallery demo) automatically feeds context-consuming components the
// same way the real app does, instead of each demo wiring up a partial set by
// hand. Pass `false` for hosts that already provide these contexts themselves
// via `contextMixin` (the full app shell — `ha-demo`, `ha-test`), to avoid
// registering duplicate providers on the same element.
provideContexts = true
setHassProperty = 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;
// The individual (non-grouped) contexts that contextMixin also provides.
// Components such as ha-area-picker / ha-entity-picker consume these directly
// (e.g. `Object.values(areas)`), so they must be provided alongside the
// grouped contexts or those components throw once they render.
const singleContextProviders = provideContexts
? {
states: new ContextProvider(baseEl(), { context: statesContext }),
services: new ContextProvider(baseEl(), { context: servicesContext }),
entities: new ContextProvider(baseEl(), { context: entitiesContext }),
devices: new ContextProvider(baseEl(), { context: devicesContext }),
areas: new ContextProvider(baseEl(), { context: areasContext }),
floors: new ContextProvider(baseEl(), { context: floorsContext }),
}
: undefined;
const updateContextProviders = (newHass: HomeAssistant) => {
if (contextProviders) {
(
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
)
);
});
}
if (singleContextProviders) {
(
Object.keys(
singleContextProviders
) as (keyof typeof singleContextProviders)[]
).forEach((key) => {
(singleContextProviders[key] as ContextProvider<any>).setValue(
newHass[key]
);
});
}
};
const wsCommands = {};
const restResponses: [string | RegExp, MockRestCallback][] = [];
@@ -484,7 +396,6 @@ export const provideHass = (
elements.forEach((el) => {
el.hass = newHass;
});
updateContextProviders(newHass);
},
updateStates,
updateTranslations,
@@ -546,10 +457,6 @@ 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,12 +793,6 @@ 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;
@@ -228,6 +228,7 @@ 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}
@@ -251,6 +251,7 @@ class DialogAreaDetail
>
<div class="content">
<ha-entity-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.areas.editor.temperature_entity"
)}
@@ -265,6 +266,7 @@ class DialogAreaDetail
></ha-entity-picker>
<ha-entity-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.areas.editor.humidity_entity"
)}
+52 -22
View File
@@ -14,7 +14,10 @@ import {
mdiShape,
mdiTools,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket/dist/types";
import type {
HassEntity,
UnsubscribeFunc,
} 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";
@@ -56,6 +59,7 @@ 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";
@@ -65,6 +69,7 @@ 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";
@@ -138,7 +143,7 @@ const NAVIGATION_ACTIONS: {
const MAX_COLUMNS = 3;
@customElement("ha-config-area-page")
class HaConfigAreaPage extends LitElement {
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public areaId!: string;
@@ -153,6 +158,8 @@ class HaConfigAreaPage extends LitElement {
@state() private _related?: RelatedResult;
@state() private _newTriggersConditions = false;
private _logbookTime = { recent: 86400 };
private _columnsController = createColumnsController(this, MAX_COLUMNS);
@@ -248,6 +255,23 @@ class HaConfigAreaPage extends LitElement {
}
}
// 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;
@@ -353,26 +377,32 @@ class HaConfigAreaPage extends LitElement {
></ha-icon-button>
</div>`
: nothing}
<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>
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>`
: nothing}
</div>`}
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
@@ -1,4 +1,3 @@
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";
@@ -22,9 +21,10 @@ 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,7 +42,10 @@ import "../../condition/types/ha-automation-condition-zone";
import type { ActionElement } from "../ha-automation-action-row";
@customElement("ha-automation-action-condition")
export class HaConditionAction extends LitElement implements ActionElement {
export class HaConditionAction
extends SubscribeMixin(LitElement)
implements ActionElement
{
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@@ -55,9 +58,7 @@ export class HaConditionAction extends LitElement implements ActionElement {
@property({ type: Boolean, attribute: "indent" }) public indent = false;
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@state() private _conditionDescriptions: ConditionDescriptions = {};
@query("ha-automation-condition-editor")
private _conditionEditor?: HaAutomationConditionEditor;
@@ -66,6 +67,21 @@ export class HaConditionAction extends LitElement implements ActionElement {
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,7 +7,10 @@ import {
mdiHelpCircleOutline,
mdiPlus,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type {
HassServiceTarget,
UnsubscribeFunc,
} 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";
@@ -77,16 +80,13 @@ import {
CONDITION_COLLECTIONS,
getConditionDomain,
getConditionObjectId,
subscribeConditions,
} from "../../../data/condition";
import {
getConfigEntries,
type ConfigEntry,
} from "../../../data/config_entries";
import {
conditionDescriptionsContext,
labelsContext,
triggerDescriptionsContext,
} from "../../../data/context";
import { labelsContext } 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,6 +101,7 @@ 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,
@@ -115,6 +116,7 @@ import {
TRIGGER_COLLECTIONS,
getTriggerDomain,
getTriggerObjectId,
subscribeTriggers,
} from "../../../data/trigger";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
@@ -192,11 +194,6 @@ const DYNAMIC_KEYWORDS = [
const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
// Group keys surfaced as their own section in the "by target" tab because
// their elements have no target (time/calendar/schedule, sun). Picking one
// drills into its items, like selecting the matching group in the "by type" tab.
const TIME_LOCATION_GROUPS = ["time", "sun"];
type CollectionGroupType = "helper" | "other" | "dynamic" | "customDynamic";
@customElement("add-automation-element-dialog")
@@ -230,13 +227,7 @@ class DialogAddAutomationElement
@state() private _narrow = false;
@state()
@consume({ context: triggerDescriptionsContext, subscribe: true })
private _triggerDescriptions: TriggerDescriptions = {};
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@state() private _triggerDescriptions: TriggerDescriptions = {};
@state() private _targetItems?: {
title: string;
@@ -245,8 +236,12 @@ 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[];
@@ -264,6 +259,10 @@ class DialogAddAutomationElement
// #region variables
private _unsub?: Promise<UnsubscribeFunc>;
private _unsubscribeLabFeatures?: Promise<UnsubscribeFunc>;
private _configEntryLookup: Record<string, ConfigEntry> = {};
private _closing = false;
@@ -279,6 +278,31 @@ 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 {
@@ -310,9 +334,28 @@ 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(
@@ -329,9 +372,11 @@ 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);
@@ -340,7 +385,11 @@ class DialogAddAutomationElement
// prevent view mode switch when resizing window
this._bottomSheetMode = this._narrow;
if (queryTarget && !this._selectedTarget) {
if (
queryTarget &&
this._newTriggersAndConditions &&
!this._selectedTarget
) {
this._selectedTarget = queryTarget;
this._tab = "targets";
this._getItemsByTarget();
@@ -385,6 +434,7 @@ class DialogAddAutomationElement
}
this.removeKeyboardShortcuts();
this._unsubscribe();
if (this._params) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -400,7 +450,7 @@ class DialogAddAutomationElement
this._selectedCollectionIndex = undefined;
this._selectedGroup = undefined;
this._selectedTarget = undefined;
this._tab = "targets";
this._tab = this._newTriggersAndConditions ? "targets" : "groups";
this._filter = "";
this._manifests = undefined;
this._domains = undefined;
@@ -539,6 +589,7 @@ class DialogAddAutomationElement
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("resize", this._updateNarrow);
this._unsubscribe();
}
protected supportedShortcuts(): SupportedShortcuts {
@@ -547,10 +598,39 @@ 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;
@@ -584,12 +664,6 @@ 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"
@@ -598,6 +672,15 @@ 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"),
@@ -680,6 +763,7 @@ class DialogAddAutomationElement
this._manifests
)}
.convertToItem=${this._convertToItem}
.newTriggersAndConditions=${this._newTriggersAndConditions}
@search-element-picked=${this._searchItemSelected}
>
</ha-automation-add-search>`
@@ -688,28 +772,13 @@ class DialogAddAutomationElement
.hass=${this.hass}
.value=${this._selectedTarget}
@value-changed=${this._handleTargetSelected}
@time-location-group-selected=${this
._handleTimeLocationGroupSelected}
.narrow=${this._narrow}
.timeLocationLabel=${this._getTimeLocationLabel(
automationElementType
)}
.timeLocationGroups=${this._getTimeLocationGroups(
automationElementType,
this.hass.localize,
automationElementType === "condition"
? this._conditionDescriptions
: this._triggerDescriptions
)}
.selectedGroup=${this._selectedGroup}
class=${classMap({
"ha-scrollbar": true,
hidden:
!!this._getAddFromTargetHidden(
this._narrow,
this._selectedTarget
) ||
(this._narrow && !!this._selectedGroup),
hidden: !!this._getAddFromTargetHidden(
this._narrow,
this._selectedTarget
),
})}
.manifests=${this._manifests}
></ha-automation-add-from-target>`
@@ -815,13 +884,13 @@ class DialogAddAutomationElement
)
: undefined}
.selectLabel=${this.hass.localize(
`ui.panel.config.automation.editor.${this._tab === "groups" || this._selectedGroup ? `${automationElementType}s.select` : "select_target"}` as LocalizeKeys
`ui.panel.config.automation.editor.${this._tab === "groups" ? `${automationElementType}s.select` : "select_target"}` as LocalizeKeys
)}
.emptyLabel=${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target`
)}
.tooltipDescription=${this._tab === "targets" &&
!this._selectedGroup}
.emptyNote=${this._getEmptyNote(automationElementType)}
.tooltipDescription=${this._tab === "targets"}
.target=${(this._tab === "targets" &&
this._selectedTarget &&
([
@@ -981,7 +1050,7 @@ class DialogAddAutomationElement
items: this._getBlockItems(this._params!.type, this.hass.localize),
},
]
: !this._filter && this._selectedGroup
: !this._filter && this._tab === "groups" && this._selectedGroup
? [
{
title: this.hass.localize(
@@ -1039,10 +1108,7 @@ class DialogAddAutomationElement
Object.entries(grp).map(([key, options]) =>
options.members
? flattenGroups(options.members)
: options.domains
? // domain elements are appended below from the backend descriptions
[]
: this._convertToItem(key, options, type, localize)
: this._convertToItem(key, options, type, localize)
);
const items = flattenGroups(groups).flat();
@@ -1083,8 +1149,6 @@ class DialogAddAutomationElement
let genericCollectionIndex = -1;
let dynamicCollectionIndex = -1;
const exclusiveDomains = this._getExclusiveDomains(type);
collections.forEach((collection, index) => {
let collectionGroups = Object.entries(collection.groups);
const groups: AddAutomationElementListItem[] = [];
@@ -1115,8 +1179,7 @@ class DialogAddAutomationElement
triggerDescriptions,
manifests,
domains,
types,
exclusiveDomains
types
)
);
@@ -1135,8 +1198,7 @@ class DialogAddAutomationElement
conditionDescriptions,
manifests,
domains,
types,
exclusiveDomains
types
)
);
@@ -1169,19 +1231,9 @@ class DialogAddAutomationElement
}
groups.push(
...collectionGroups
.filter(([, options]) =>
this._groupHasItems(
type,
options,
type === "condition"
? conditionDescriptions
: triggerDescriptions
)
)
.map(([key, options]) =>
this._convertToItem(key, options, type, localize)
)
...collectionGroups.map(([key, options]) =>
this._convertToItem(key, options, type, localize)
)
);
if (groups.length) {
@@ -1278,28 +1330,11 @@ class DialogAddAutomationElement
return this._services(localize, services, manifests, group);
}
const groupDef =
TYPES[type].collections[collectionIndex]?.groups[group] ??
TYPES[type].collections.find((collection) => group in collection.groups)
?.groups[group];
const groups = this._getGroups(type, group, collectionIndex);
let result: AddAutomationElementListItem[];
if (groupDef?.domains && !groupDef.members) {
// Curated group whose items come solely from backend domains (e.g. Sun).
result = this._getDomainElementItems(type, groupDef.domains, localize);
} else {
const groups = this._getGroups(type, group, collectionIndex);
result = Object.entries(groups).map(([key, options]) =>
this._convertToItem(key, options, type, localize)
);
if (groupDef?.domains) {
// Curated group with both static members and backend domains (Time).
result.push(
...this._getDomainElementItems(type, groupDef.domains, localize)
);
}
}
const result = Object.entries(groups).map(([key, options]) =>
this._convertToItem(key, options, type, localize)
);
if (type === "action") {
if (!this._selectedGroup) {
@@ -1419,8 +1454,7 @@ class DialogAddAutomationElement
triggers: TriggerDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[],
exclusiveDomains: Set<string>
types: CollectionGroupType[]
): AddAutomationElementListItem[] => {
if (!triggers || !manifests) {
return [];
@@ -1430,7 +1464,7 @@ class DialogAddAutomationElement
Object.keys(triggers).forEach((trigger) => {
const domain = getTriggerDomain(trigger);
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
if (addedDomains.has(domain)) {
return;
}
addedDomains.add(domain);
@@ -1492,8 +1526,7 @@ class DialogAddAutomationElement
conditions: ConditionDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[],
exclusiveDomains: Set<string>
types: CollectionGroupType[]
): AddAutomationElementListItem[] => {
if (!conditions || !manifests) {
return [];
@@ -1503,7 +1536,7 @@ class DialogAddAutomationElement
Object.keys(conditions).forEach((condition) => {
const domain = getConditionDomain(condition);
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
if (addedDomains.has(domain)) {
return;
}
addedDomains.add(domain);
@@ -1763,93 +1796,22 @@ class DialogAddAutomationElement
options,
type: AddAutomationElementDialogParams["type"],
localize: LocalizeFunc
): AddAutomationElementListItem => {
// A group either lists explicit members or bundles backend element domains.
const isGroup = !!(options.members || options.domains);
return {
key,
name: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
isGroup ? "groups" : "type"
}.${key}.label`
),
description: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
isGroup ? "groups" : "type"
}.${key}.description${isGroup ? "" : ".picker"}`
),
iconPath: options.icon || TYPES[type].icons[key],
};
};
// Domains owned exclusively by a curated group, i.e. a group that bundles
// only domains and no static members (e.g. "sun" under the Sun group). Those
// are hidden from the generic dynamic domain grouping so they don't appear
// both standalone and inside the curated group. Domains of a mixed group
// (static members + domains, e.g. "calendar"/"schedule" under Time) are NOT
// hidden — they still surface as their own domain group as well.
private _getExclusiveDomains = memoizeOne(
(type: AddAutomationElementDialogParams["type"]): Set<string> => {
const domains = new Set<string>();
TYPES[type].collections.forEach((collection) =>
Object.values(collection.groups).forEach((group) => {
if (group.domains && !group.members) {
group.domains.forEach((domain) => domains.add(domain));
}
})
);
return domains;
}
);
private _getDomainElementItems(
type: AddAutomationElementDialogParams["type"],
domains: string[],
localize: LocalizeFunc
): AddAutomationElementListItem[] {
const domainSet = new Set(domains);
if (type === "trigger") {
return Object.keys(this._triggerDescriptions)
.filter((trigger) => domainSet.has(getTriggerDomain(trigger)))
.map((trigger) =>
this._getTriggerListItem(localize, getTriggerDomain(trigger), trigger)
);
}
if (type === "condition") {
return Object.keys(this._conditionDescriptions)
.filter((condition) => domainSet.has(getConditionDomain(condition)))
.map((condition) =>
this._getConditionListItem(
localize,
getConditionDomain(condition),
condition
)
);
}
return [];
}
private _groupHasItems(
type: AddAutomationElementDialogParams["type"],
options: { members?: object; domains?: string[] },
descriptions: TriggerDescriptions | ConditionDescriptions
): boolean {
if (options.members && Object.keys(options.members).length) {
return true;
}
if (options.domains) {
const domainSet = new Set(options.domains);
const getDomain =
type === "condition" ? getConditionDomain : getTriggerDomain;
return Object.keys(descriptions).some((key) =>
domainSet.has(getDomain(key))
);
}
// plain single-element group
return true;
}
): AddAutomationElementListItem => ({
key,
name: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
options.members ? "groups" : "type"
}.${key}.label`
),
description: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
options.members ? "groups" : "type"
}.${key}.description${options.members ? "" : ".picker"}`
),
iconPath: options.icon || TYPES[type].icons[key],
});
private _getDomainGroupedListItems(
localize: LocalizeFunc,
@@ -2093,8 +2055,6 @@ class DialogAddAutomationElement
) => {
this._targetItems = undefined;
this._loadItemsError = false;
this._selectedGroup = undefined;
this._selectedCollectionIndex = undefined;
this._selectedTarget = ev.detail.value;
mainWindow.history.pushState(
{
@@ -2116,67 +2076,6 @@ class DialogAddAutomationElement
this._getItemsByTarget();
};
// Time & location groups have no target; picking one drills into its items
// (the same list as the matching group in the "by type" tab).
private _handleTimeLocationGroupSelected = (
ev: ValueChangedEvent<string>
) => {
this._targetItems = undefined;
this._loadItemsError = false;
this._selectedTarget = undefined;
this._selectedGroup = ev.detail.value;
this._selectedCollectionIndex = 0;
mainWindow.history.pushState(
{
dialogData: {
group: this._selectedGroup,
collectionIndex: this._selectedCollectionIndex,
},
},
""
);
requestAnimationFrame(() => {
if (this._narrow) {
this._contentElement?.scrollTo(0, 0);
} else {
this._itemsListElement?.scrollTo(0, 0);
}
});
};
private _getTimeLocationLabel(
type: AddAutomationElementDialogParams["type"]
): string | undefined {
if (type !== "trigger" && type !== "condition") {
return undefined;
}
return this.hass.localize("ui.panel.config.automation.editor.time_sun");
}
private _getTimeLocationGroups = memoizeOne(
(
type: AddAutomationElementDialogParams["type"],
localize: LocalizeFunc,
descriptions: TriggerDescriptions | ConditionDescriptions
): AddAutomationElementListItem[] => {
if (type !== "trigger" && type !== "condition") {
return [];
}
return TIME_LOCATION_GROUPS.map(
(group) => [group, TYPES[type].collections[0].groups[group]] as const
)
.filter(
([, options]) =>
options && this._groupHasItems(type, options, descriptions)
)
.map(([group, options]) =>
this._convertToItem(group, options, type, localize)
)
.filter((item) => item.name);
}
);
private _getDefaultStateItems(
type: "trigger" | "condition"
): AddAutomationElementListItem[] {
@@ -63,7 +63,6 @@ import {
} from "../../../../data/target";
import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
import type { AddAutomationElementListItem } from "../add-automation-element-dialog";
interface Level1Entries {
open: boolean;
@@ -94,16 +93,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
@property({ attribute: false }) public manifests?: DomainManifestLookup;
// Section title + group rows (Time, Location) for the targetless element
// groups. Picking a row drills into that group's items, just like selecting
// the matching group in the "by type" tab.
@property({ attribute: false }) public timeLocationLabel?: string;
@property({ attribute: false })
public timeLocationGroups?: AddAutomationElementListItem[];
@property({ attribute: false }) public selectedGroup?: string;
// #endregion properties
// #region context
@@ -193,20 +182,8 @@ export default class HaAutomationAddFromTarget extends LitElement {
? this._renderNarrow(this._entries, this.value)
: html`
${this._renderFloors(this.narrow, this._entries, this.value)}
${this._renderTimeLocation(
this.narrow,
this.timeLocationLabel,
this.timeLocationGroups,
this.selectedGroup
)}
${this._renderUnassigned(this.narrow, this._entries, this.value)}
${this._renderLabels(
this.narrow,
this.states,
this._registries,
this._labelRegistry,
this.value
)}
${this._renderLabels(this.narrow, this.value)}
`}
${this.narrow && this._showShowMoreButton && !this._fullHeight
? html`
@@ -366,58 +343,14 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
);
private _renderTimeLocation = memoizeOne(
(
narrow: boolean,
label?: string,
groups?: AddAutomationElementListItem[],
selectedGroup?: string
) => {
if (!label || !groups?.length) {
return nothing;
}
return html`<ha-section-title>${label}</ha-section-title>
<ha-list-base>
${groups.map(
(group) =>
html`<ha-list-item-button
.value=${group.key}
@click=${this._selectTimeLocationGroup}
class=${group.key === selectedGroup ? "selected" : ""}
>
${group.icon
? html`<span slot="start">${group.icon}</span>`
: group.iconPath
? html`<ha-svg-icon
slot="start"
.path=${group.iconPath}
></ha-svg-icon>`
: nothing}
<div slot="headline">${group.name}</div>
${narrow
? html`<ha-icon-next slot="end"></ha-icon-next>`
: nothing}
</ha-list-item-button>`
)}
</ha-list-base>`;
}
);
private _renderLabels = memoizeOne(
(
narrow: boolean,
states: ContextType<typeof statesContext>,
registries: ContextType<typeof registriesContext>,
labelRegistry: LabelRegistryEntry[],
value?: SingleHassServiceTarget
) => {
(narrow: boolean, value?: SingleHassServiceTarget) => {
const labels = this._getLabelsMemoized(
states,
registries.areas,
registries.devices,
registries.entities,
labelRegistry,
this.states,
this._registries.areas,
this._registries.devices,
this._registries.entities,
this._labelRegistry,
undefined,
undefined,
undefined,
@@ -1240,13 +1173,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
}
private _selectTimeLocationGroup(ev: CustomEvent) {
const value = (ev.currentTarget as any).value;
if (value) {
fireEvent(this, "time-location-group-selected", { value });
}
}
private async _valueChanged(itemId: string, expand = false) {
const [type, id] = itemId.split(TARGET_SEPARATOR, 2);
@@ -1586,7 +1512,4 @@ declare global {
interface HTMLElementTagNameMap {
"ha-automation-add-from-target": HaAutomationAddFromTarget;
}
interface HASSDomEvents {
"time-location-group-selected": { value: string };
}
}
@@ -1,5 +1,5 @@
import { mdiInformationOutline, mdiPlus } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html, nothing, type TemplateResult } from "lit";
import {
customElement,
eventOptions,
@@ -40,6 +40,8 @@ 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!: (
@@ -81,6 +83,9 @@ 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,
@@ -227,6 +232,17 @@ 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,6 +117,9 @@ 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,
@@ -206,6 +209,7 @@ export class HaAutomationAddSearch extends LitElement {
this.filter,
this.configEntryLookup,
this.items,
this.newTriggersAndConditions,
this._selectedSearchSection,
this._relatedIdSets
);
@@ -256,13 +260,19 @@ 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");
}
searchSections.push(...TARGET_SEARCH_SECTIONS);
if (this.newTriggersAndConditions) {
searchSections.push(...TARGET_SEARCH_SECTIONS);
}
return html`
<ha-chip-set class="sections">
${searchSections.map((section) =>
@@ -492,6 +502,7 @@ export class HaAutomationAddSearch extends LitElement {
searchTerm: string,
configEntryLookup: Record<string, ConfigEntry>,
automationItems: AddAutomationElementListItem[],
newTriggersAndConditions: boolean,
selectedSection?: SearchSection,
relatedIdSets?: RelatedIdSets
) => {
@@ -559,185 +570,191 @@ export class HaAutomationAddSearch extends LitElement {
resultItems.push(...blocks);
}
if (!selectedSection || selectedSection === "entity") {
let entityItems = this._getEntitiesMemoized(
this.hass,
`entity${TARGET_SEPARATOR}`
);
if (newTriggersAndConditions) {
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 || ""
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)
),
})) as EntityComboBoxItem[];
}
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
);
if (searchTerm) {
entityItems = sortRelatedFirst(
this._filterGroup(
"entity",
entityItems,
searchTerm,
entityComboBoxKeys
)
) as EntityComboBoxItem[];
} else if (relatedIdSets?.entities.size) {
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
}
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")
);
}
if (!selectedSection && entityItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.entities")
...areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
) {
return {
...item,
last: true,
};
}
return item;
})
);
}
resultItems.push(...entityItems);
}
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 (!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,
if (searchTerm) {
labels = this._filterGroup(
"label",
labels,
searchTerm,
deviceComboBoxKeys
)
);
} else if (relatedIdSets?.devices.size) {
deviceItems = sortRelatedFirst(deviceItems);
labelComboBoxKeys
);
}
if (!selectedSection && labels.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.labels")
);
}
resultItems.push(...labels);
}
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;
@@ -1,7 +1,9 @@
import { consume } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
@@ -17,8 +19,12 @@ import {
type Condition,
} from "../../../../data/automation";
import type { ConditionDescriptions } from "../../../../data/condition";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import { conditionDescriptionsContext } from "../../../../data/context";
import {
CONDITION_BUILDING_BLOCKS,
subscribeConditions,
} from "../../../../data/condition";
import { subscribeLabFeature } from "../../../../data/labs";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
import {
getAddAutomationElementTargetFromQuery,
@@ -32,7 +38,7 @@ import type HaAutomationConditionRow from "./ha-automation-condition-row";
@customElement("ha-automation-condition")
export default class HaAutomationCondition extends AutomationSortableListMixin<Condition>(
LitElement
SubscribeMixin(LitElement)
) {
@property({ attribute: false }) public conditions!: Condition[];
@@ -42,13 +48,16 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
@property({ type: Boolean, attribute: false }) public editorDirty = false;
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@state() 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[] {
@@ -63,6 +72,49 @@ 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");
@@ -1,21 +1,13 @@
import { mdiAlertOutline, mdiHelpCircleOutline } from "@mdi/js";
import { mdiHelpCircleOutline } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-tooltip";
import type {
ForDict,
PlatformCondition,
} from "../../../../../data/automation";
import type { PlatformCondition } from "../../../../../data/automation";
import {
getConditionDomain,
getConditionObjectId,
@@ -23,21 +15,11 @@ import {
} from "../../../../../data/condition";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import { getRecorderEntityOptions } from "../../../../../data/recorder";
import type { TargetSelector } from "../../../../../data/selector";
import {
extractFromTarget,
getTargetEntityCount,
} from "../../../../../data/target";
import { getTargetEntityCount } from "../../../../../data/target";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
// Mirrors `MAX_HISTORY_PRIMING_LOOKBACK` in homeassistant/helpers/condition.py:
// when a condition has a `for:` duration, the recorder is only queried this far
// back to prime it at setup, so longer durations can't be fully satisfied from
// history after a restart or reload.
const MAX_HISTORY_PRIMING_LOOKBACK_HOURS = 6;
const showOptionalToggle = (field: ConditionDescription["fields"][string]) =>
field.selector &&
!field.required &&
@@ -59,11 +41,6 @@ export class HaPlatformCondition extends LitElement {
@state() private _resolvedTargetEntityCount?: number;
@state() private _targetHasUnrecordedEntity = false;
// Incremented on each recording check so stale async responses are ignored.
private _recordingCheckId = 0;
public static get defaultConfig(): PlatformCondition {
return { condition: "" };
}
@@ -74,26 +51,6 @@ export class HaPlatformCondition extends LitElement {
this.hass.loadBackendTranslation("conditions");
this.hass.loadBackendTranslation("selector");
}
// The `for:` priming info depends on both the condition (target + duration)
// and the description (whether the condition targets entities at all), which
// can arrive in separate updates.
if (
changedProperties.has("condition") ||
changedProperties.has("description")
) {
const previousCondition = changedProperties.get("condition") as
| undefined
| this["condition"];
if (
changedProperties.has("description") ||
previousCondition?.target !== this.condition?.target ||
previousCondition?.options?.for !== this.condition?.options?.for
) {
this._updateDurationPrimingInfo();
}
}
if (!changedProperties.has("condition")) {
return;
}
@@ -306,7 +263,7 @@ export class HaPlatformCondition extends LitElement {
@click=${showOptional ? this._toggleCheckbox : undefined}
>${this.hass.localize(
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.name`
) || fieldName}${this._renderForPrimingInfo(fieldName)}</span
) || fieldName}</span
>
${description
? html`<span
@@ -515,118 +472,6 @@ export class HaPlatformCondition extends LitElement {
}
}
// Shows a small info icon beside the `for` duration field's label, with a
// tooltip explaining when history priming can't fully cover the duration.
private _renderForPrimingInfo(fieldName: string) {
if (fieldName !== "for") {
return nothing;
}
const text = this._durationPrimingInfoText();
if (!text) {
return nothing;
}
return html`<ha-svg-icon
id="for-priming-info"
tabindex="0"
class="priming-info-icon"
.path=${mdiAlertOutline}
@click=${stopPropagation}
></ha-svg-icon>
<ha-tooltip for="for-priming-info">${text}</ha-tooltip>`;
}
private _durationPrimingInfoText(): string | undefined {
const forValue = this.condition.options?.for;
// Priming only happens for entity conditions that have a `for:` duration.
if (
forValue === undefined ||
forValue === "" ||
!this.description?.target
) {
return undefined;
}
if (this._targetHasUnrecordedEntity) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.duration_priming.entity_not_recorded"
);
}
if (this._durationExceedsLookback(forValue)) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.duration_priming.history_capped",
{ hours: MAX_HISTORY_PRIMING_LOOKBACK_HOURS }
);
}
return undefined;
}
private _durationExceedsLookback(forValue: unknown): boolean {
const duration = createDurationData(
forValue as string | number | ForDict | undefined
);
if (!duration) {
return false;
}
return (
durationDataToSeconds(duration) >
MAX_HISTORY_PRIMING_LOOKBACK_HOURS * 3600
);
}
private async _updateDurationPrimingInfo(): Promise<void> {
const forValue = this.condition.options?.for;
const target = this.condition.target;
// Recording status only matters for an entity condition that has both a
// target and a `for:` duration.
const checkId = ++this._recordingCheckId;
if (
forValue === undefined ||
forValue === "" ||
!this.description?.target ||
!target ||
!this.hass.config.components.includes("recorder")
) {
this._targetHasUnrecordedEntity = false;
return;
}
try {
const { referenced_entities } = await extractFromTarget(
this.hass.callWS,
target
);
// Ignore if a newer check superseded this one.
if (checkId !== this._recordingCheckId) {
return;
}
if (!referenced_entities.length) {
this._targetHasUnrecordedEntity = false;
return;
}
const recordingDisabled = await Promise.all(
referenced_entities.map((entityId) =>
getRecorderEntityOptions(this.hass, entityId)
.then((options) => options.recording_disabled_by !== null)
// Unknown entity or command unavailable on older cores: don't warn.
.catch(() => false)
)
);
if (checkId !== this._recordingCheckId) {
return;
}
this._targetHasUnrecordedEntity = recordingDisabled.some(Boolean);
} catch (_err) {
// Target resolution failed; fall back to no warning rather than guessing.
if (checkId === this._recordingCheckId) {
this._targetHasUnrecordedEntity = false;
}
}
}
static styles = css`
:host {
display: block;
@@ -682,15 +527,6 @@ export class HaPlatformCondition extends LitElement {
.clickable {
cursor: pointer;
}
.priming-info-icon {
--mdc-icon-size: 16px;
width: 16px;
height: 16px;
color: var(--warning-color);
margin-inline-start: var(--ha-space-1);
vertical-align: middle;
cursor: help;
}
`;
}
@@ -38,6 +38,7 @@ export class HaZoneCondition extends LitElement {
)}
.value=${entity_id}
@value-changed=${this._entityPicked}
.hass=${this.hass}
.disabled=${this.disabled}
.entityFilter=${zoneAndLocationFilter}
></ha-entity-picker>
@@ -47,6 +48,7 @@ export class HaZoneCondition extends LitElement {
)}
.value=${zone}
@value-changed=${this._zonePicked}
.hass=${this.hass}
.disabled=${this.disabled}
.includeDomains=${includeDomains}
></ha-entity-picker>
@@ -1,7 +1,9 @@
import { consume } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -17,9 +19,10 @@ import {
type Trigger,
type TriggerList,
} from "../../../../data/automation";
import { triggerDescriptionsContext } from "../../../../data/context";
import { subscribeLabFeature } from "../../../../data/labs";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
import {
getAddAutomationElementTargetFromQuery,
@@ -33,7 +36,7 @@ import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
@customElement("ha-automation-trigger")
export default class HaAutomationTrigger extends AutomationSortableListMixin<Trigger>(
LitElement
SubscribeMixin(LitElement)
) {
@property({ attribute: false }) public triggers!: Trigger[];
@@ -43,9 +46,12 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
@property({ type: Boolean, attribute: false }) public editorDirty = false;
@state()
@consume({ context: triggerDescriptionsContext, subscribe: true })
private _triggerDescriptions: TriggerDescriptions = {};
@state() private _triggerDescriptions: TriggerDescriptions = {};
// @ts-ignore
@state() private _newTriggersAndConditions = false;
private _unsub?: Promise<UnsubscribeFunc>;
private _openedAddDialogFromQuery = false;
@@ -61,6 +67,49 @@ 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,6 +45,7 @@ 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
@@ -54,6 +55,7 @@ export class HaZoneTrigger extends LitElement {
.value=${zone}
.disabled=${this.disabled}
@value-changed=${this._zonePicked}
.hass=${this.hass}
.includeDomains=${includeDomains}
></ha-entity-picker>
+2
View File
@@ -99,6 +99,7 @@ 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 ||
@@ -118,6 +119,7 @@ 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,6 +50,7 @@ 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"
)}
@@ -220,6 +220,7 @@ 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 } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { consume, type ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import {
@@ -12,10 +12,27 @@ 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 {
@@ -31,11 +48,21 @@ 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")
@@ -48,25 +75,76 @@ 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 firstUpdated() {
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);
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 });
}
@@ -90,12 +168,14 @@ export class DialogDeviceAddTo extends LitElement {
)}
@closed=${this._dialogClosed}
>
${this._renderOptions()}
${this._params.newTriggersConditions
? this._renderNewOptions()
: this._renderLegacyOptions()}
</ha-adaptive-dialog>
`;
}
private _renderOptions() {
private _renderNewOptions() {
if (!this._params) {
return nothing;
}
@@ -158,6 +238,112 @@ 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 {
@@ -194,7 +380,12 @@ export class DialogDeviceAddTo extends LitElement {
return;
}
this._handleAddToAction(action.key);
if (action.kind === "add-to") {
this._handleAddToAction(action.key);
return;
}
this._handleLegacyAction(action.legacyType);
}
private _handleAddToAction(key: AddToAutomationScriptActionKey) {
@@ -206,6 +397,30 @@ 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;
@@ -224,6 +439,12 @@ export class DialogDeviceAddTo extends LitElement {
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
.loading,
.empty {
padding: var(--ha-space-4);
text-align: center;
}
`,
];
}
@@ -3,6 +3,7 @@ import type { DeviceRegistryEntry } from "../../../../data/device/device_registr
export interface DeviceAddToDialogParams {
device: DeviceRegistryEntry;
newTriggersConditions: boolean;
entityIds: string[];
canCreateScene: boolean;
}
@@ -68,6 +68,7 @@ import {
removeConfigEntryFromDevice,
updateDeviceRegistryEntry,
} from "../../../data/device/device_registry";
import { subscribeLabFeature } from "../../../data/labs";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import {
fetchDiagnosticHandler,
@@ -203,6 +204,10 @@ export class HaConfigDevicePage extends LitElement {
private _deviceAlertsActionsTimeout?: number;
@state() private _newTriggersConditions = false;
private _unsubLabFeature?: (() => void) | undefined;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -372,6 +377,7 @@ export class HaConfigDevicePage extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
loadDeviceRegistryDetailDialog();
this._subscribeLabFeature();
}
protected updated(changedProps: PropertyValues<this>) {
@@ -388,6 +394,7 @@ export class HaConfigDevicePage extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this._deviceAlertsActionsTimeout);
this._unsubLabFeature?.();
}
protected render() {
@@ -1397,6 +1404,7 @@ export class HaConfigDevicePage extends LitElement {
);
showDeviceAddToDialog(this, {
device,
newTriggersConditions: this._newTriggersConditions,
entityIds: sceneEntityIds,
canCreateScene:
isComponentLoaded(this.hass.config, "scene") &&
@@ -1404,6 +1412,23 @@ 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[],
@@ -269,6 +269,7 @@ 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,6 +328,7 @@ 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"
@@ -415,6 +416,7 @@ 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,6 +230,7 @@ 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,6 +747,7 @@ 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"
@@ -1010,13 +1011,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
)}</span
>
<span slot="secondary">
${this.entry.aliases.filter((a) => a !== null).length
? this.entry.aliases
.filter((a): a is string => a !== null)
.join(", ")
: this.hass.localize(
"ui.dialogs.entity_registry.editor.no_aliases"
)}
${this.hass.localize(
"ui.dialogs.entity_registry.editor.voice_assistants_description"
)}
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
@@ -13,8 +13,6 @@ 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";
@@ -23,7 +21,6 @@ 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";
@@ -69,46 +66,20 @@ class ZHAConfigDashboard extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
if (!this.hass) {
return;
if (this.hass) {
this.hass.loadBackendTranslation("config_panel", "zha", false);
this._fetchConfigEntry();
this._fetchConfiguration();
this._fetchDevicesAndGroups();
}
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 {
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 devices = this._configEntry
? Object.values(this.hass.devices).filter((device) =>
device.config_entries.includes(this._configEntry!.entry_id)
)
: [];
const deviceCount = devices.length;
let entityCount = 0;
@@ -492,12 +463,6 @@ 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;
@@ -219,6 +219,7 @@ class DialogPersonDetail
)}
</p>
<ha-entities-picker
.hass=${this.hass}
.value=${this._deviceTrackers}
.includeDomains=${includeDomains}
.pickedEntityLabel=${this.hass.localize(
@@ -584,6 +584,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
<ha-entity-picker
@value-changed=${this._entityPicked}
.excludeDomains=${SCENE_IGNORED_DOMAINS}
.hass=${this.hass}
label=${this.hass.localize(
"ui.panel.config.scene.editor.entities.add"
)}
@@ -0,0 +1,95 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/input/ha-input";
import type { AlexaEntityConfig } from "../../../data/alexa";
import { fetchCloudAlexaEntity } from "../../../data/alexa";
import { updateCloudAlexaEntityConfig } from "../../../data/cloud";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@customElement("alexa-entity-voice-settings")
export class AlexaEntityVoiceSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@state() private _entity?: AlexaEntityConfig;
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("entityId") && this.entityId) {
this._fetchEntity();
}
}
private async _fetchEntity() {
try {
this._entity = await fetchCloudAlexaEntity(this.hass, this.entityId);
} catch (_err) {
this._entity = undefined;
}
}
protected render() {
if (!this._entity) {
return nothing;
}
const defaultName = this.hass.states[this.entityId]
? computeStateName(this.hass.states[this.entityId])
: this.entityId;
return html`
<ha-input
.label=${this.hass.localize("ui.dialogs.voice-settings.name")}
.hint=${this.hass.localize(
"ui.dialogs.voice-settings.name_description"
)}
with-clear
.value=${this._entity.name ?? ""}
.placeholder=${defaultName}
@change=${this._nameChanged}
></ha-input>
`;
}
private async _nameChanged(ev) {
if (!this._entity) {
return;
}
const value = ev.target.value?.trim() || null;
if ((this._entity.name ?? null) === value) {
return;
}
const previous = this._entity.name ?? null;
this._entity = { ...this._entity, name: value };
try {
await updateCloudAlexaEntityConfig(this.hass, this.entityId, value);
} catch (_err) {
this._entity = { ...this._entity, name: previous };
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
margin: 0 var(--ha-space-8) var(--ha-space-8);
}
ha-input {
display: block;
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"alexa-entity-voice-settings": AlexaEntityVoiceSettings;
}
}
@@ -0,0 +1,146 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/ha-alert";
import "../../../components/ha-aliases-editor";
import "../../../components/ha-md-list-item";
import "../../../components/ha-switch";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
import { updateEntityRegistryEntry } from "../../../data/entity/entity_registry";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
@customElement("assist-entity-voice-settings")
export class AssistEntityVoiceSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry;
@state() private _aliases?: (string | null)[];
protected render() {
if (!this.entry) {
return html`<ha-alert alert-type="warning">
${this.hass.localize("ui.dialogs.voice-settings.aliases_no_unique_id", {
faq_link: html`<a
href=${documentationUrl(this.hass, "/faq/unique_id")}
target="_blank"
rel="noreferrer"
>${this.hass.localize("ui.dialogs.entity_registry.faq")}</a
>`,
})}
</ha-alert>`;
}
return html`
<ha-md-list-item>
<span slot="headline">
${this.hass.states[this.entityId]
? computeStateName(this.hass.states[this.entityId])
: this.entityId}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.dialogs.voice-settings.entity_name_alias_description"
)}
</span>
<ha-switch
slot="end"
.checked=${(this._aliases ?? this.entry.aliases).includes(null)}
@change=${this._toggleEntityNameAlias}
></ha-switch>
</ha-md-list-item>
<h4 class="header">
${this.hass.localize("ui.dialogs.voice-settings.aliases")}
</h4>
<ha-aliases-editor
.aliases=${(this._aliases ?? this.entry.aliases).filter(
(a): a is string => a !== null
)}
sortable
@value-changed=${this._aliasesChanged}
></ha-aliases-editor>
`;
}
private async _toggleEntityNameAlias(ev) {
const previous = this._aliases;
const enabled = ev.target.checked;
const currentAliases = this._aliases ?? this.entry?.aliases ?? [];
if (enabled) {
this._aliases = [null, ...currentAliases.filter((a) => a !== null)];
} else {
this._aliases = currentAliases.filter((a): a is string => a !== null);
}
await this._saveAliases(previous);
}
private _aliasesChanged(ev) {
const previous = this._aliases;
const currentAliases = this._aliases ?? this.entry?.aliases ?? [];
const hasNull = currentAliases.includes(null);
const nullAliases: (string | null)[] = hasNull ? [null] : [];
const newStringAliases: string[] = ev.detail.value;
this._aliases = [...nullAliases, ...newStringAliases];
this._saveAliases(previous);
}
private async _saveAliases(previous?: (string | null)[]) {
if (!this._aliases) {
return;
}
const hasNull = this._aliases.includes(null);
const nullAliases: null[] = hasNull ? [null] : [];
const stringAliases = this._aliases
.filter((a): a is string => a !== null)
.map((alias) => alias.trim())
.filter((alias) => alias);
try {
const result = await updateEntityRegistryEntry(this.hass, this.entityId, {
aliases: [...nullAliases, ...stringAliases],
});
fireEvent(this, "entity-entry-updated", result.entity_entry);
} catch (_err) {
this._aliases = previous;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
margin: 0 var(--ha-space-8) var(--ha-space-8);
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
}
ha-aliases-editor {
display: block;
}
.header {
margin-top: var(--ha-space-2);
margin-bottom: var(--ha-space-1);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"assist-entity-voice-settings": AssistEntityVoiceSettings;
}
interface HASSDomEvents {
"entity-entry-updated": ExtEntityRegistryEntry;
}
}
@@ -1,4 +1,4 @@
import { mdiTuneVertical } from "@mdi/js";
import { mdiChevronLeft, mdiTuneVertical } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -6,10 +6,17 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/ha-icon-button";
import "../../../components/ha-dialog";
import type { ExposeEntitySettings } from "../../../data/expose";
import { voiceAssistants } from "../../../data/expose";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import {
haStyle,
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "./entity-voice-settings";
import "./voice-assistant-settings";
import type { VoiceSettingsDialogParams } from "./show-dialog-voice-settings";
@customElement("dialog-voice-settings")
@@ -20,8 +27,14 @@ class DialogVoiceSettings extends LitElement {
@state() private _open = false;
@state() private _editingAssistant?: string;
@state() private _exposed?: ExposeEntitySettings;
public showDialog(params: VoiceSettingsDialogParams): void {
this._params = params;
this._exposed = params.exposed;
this._editingAssistant = undefined;
this._open = true;
}
@@ -31,6 +44,8 @@ class DialogVoiceSettings extends LitElement {
private _dialogClosed(): void {
this._params = undefined;
this._exposed = undefined;
this._editingAssistant = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -41,14 +56,23 @@ class DialogVoiceSettings extends LitElement {
this.closeDialog();
}
private _editAssistant(ev: CustomEvent): void {
this._editingAssistant = ev.detail.assistant;
}
private _backToList(): void {
this._editingAssistant = undefined;
}
protected render() {
if (!this._params) {
return nothing;
}
const title =
computeStateName(this.hass.states[this._params.entityId]) ||
this.hass.localize("ui.panel.config.entities.picker.unnamed_entity");
const title = this._editingAssistant
? voiceAssistants[this._editingAssistant].name
: computeStateName(this.hass.states[this._params.entityId]) ||
this.hass.localize("ui.panel.config.entities.picker.unnamed_entity");
return html`
<ha-dialog
@@ -56,28 +80,58 @@ class DialogVoiceSettings extends LitElement {
header-title=${title}
@closed=${this._dialogClosed}
>
<ha-icon-button
slot="headerActionItems"
.label=${this.hass.localize("ui.dialogs.voice-settings.view_entity")}
.path=${mdiTuneVertical}
@click=${this._viewMoreInfo}
></ha-icon-button>
<div>
<entity-voice-settings
.hass=${this.hass}
.entityId=${this._params.entityId}
.entry=${this._params.extEntityReg}
.exposed=${this._params.exposed}
@entity-entry-updated=${this._entityEntryUpdated}
@exposed-entities-changed=${this._exposedEntitiesChanged}
></entity-voice-settings>
</div>
${this._editingAssistant
? html`<ha-icon-button
slot="headerNavigationIcon"
.label=${this.hass.localize("ui.common.back")}
.path=${mdiChevronLeft}
@click=${this._backToList}
></ha-icon-button>`
: html`<ha-icon-button
slot="headerActionItems"
.label=${this.hass.localize(
"ui.dialogs.voice-settings.view_entity"
)}
.path=${mdiTuneVertical}
@click=${this._viewMoreInfo}
></ha-icon-button>`}
<div>${this._renderContent()}</div>
</ha-dialog>
`;
}
private _renderContent() {
const entityId = this._params!.entityId;
if (this._editingAssistant) {
return html`<voice-assistant-settings
.hass=${this.hass}
.entityId=${entityId}
.assistant=${this._editingAssistant}
.entry=${this._params!.extEntityReg}
@entity-entry-updated=${this._entityEntryUpdated}
></voice-assistant-settings>`;
}
return html`<entity-voice-settings
.hass=${this.hass}
.entityId=${entityId}
.entry=${this._params!.extEntityReg}
.exposed=${this._exposed!}
@edit-assistant=${this._editAssistant}
@exposed-changed=${this._exposedChanged}
@entity-entry-updated=${this._entityEntryUpdated}
@exposed-entities-changed=${this._exposedEntitiesChanged}
></entity-voice-settings>`;
}
private _exposedChanged(ev: CustomEvent): void {
this._exposed = ev.detail.value;
}
private _entityEntryUpdated(ev: CustomEvent) {
this._params!.extEntityReg = ev.detail;
this._params!.entityEntryUpdated?.(ev.detail);
}
private _exposedEntitiesChanged() {
@@ -88,6 +142,7 @@ class DialogVoiceSettings extends LitElement {
return [
haStyle,
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-content-padding: 0;
@@ -1,11 +1,10 @@
import { mdiAlertCircle } from "@mdi/js";
import { mdiAlertCircle, mdiCog } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import type {
EntityDomainFilter,
EntityDomainFilterFunc,
@@ -14,35 +13,24 @@ import {
generateEntityDomainFilter,
isEmptyEntityDomainFilter,
} from "../../../common/entity/entity_domain_filter";
import "../../../components/ha-alert";
import "../../../components/ha-aliases-editor";
import "../../../components/ha-checkbox";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-list-item";
import "../../../components/ha-switch";
import "../../../components/voice-assistant-brand-icon";
import { fetchCloudAlexaEntity } from "../../../data/alexa";
import type { CloudStatus, CloudStatusLoggedIn } from "../../../data/cloud";
import {
fetchCloudStatus,
updateCloudGoogleEntityConfig,
} from "../../../data/cloud";
import { fetchCloudStatus } from "../../../data/cloud";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import { getExtendedEntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { ExposeEntitySettings } from "../../../data/expose";
import { exposeEntities, voiceAssistants } from "../../../data/expose";
import type { GoogleEntity } from "../../../data/google_assistant";
import { fetchCloudGoogleEntity } from "../../../data/google_assistant";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { EntityRegistrySettings } from "../entities/entity-registry-settings";
@customElement("entity-voice-settings")
export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
export class EntityVoiceSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@@ -53,8 +41,6 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
@state() private _cloudStatus?: CloudStatus;
@state() private _aliases?: (string | null)[];
@state() private _googleEntity?: GoogleEntity;
@state() private _unsupported: Partial<
@@ -77,16 +63,16 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
private async _fetchEntities() {
try {
const googleEntity = await fetchCloudGoogleEntity(
this._googleEntity = await fetchCloudGoogleEntity(
this.hass,
this.entityId
);
this._googleEntity = googleEntity;
this.requestUpdate("_googleEntity");
} catch (err: any) {
if (err.code === "not_supported") {
this._unsupported["cloud.google_assistant"] = true;
this.requestUpdate("_unsupported");
this._unsupported = {
...this._unsupported,
"cloud.google_assistant": true,
};
}
}
@@ -94,8 +80,7 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
await fetchCloudAlexaEntity(this.hass, this.entityId);
} catch (err: any) {
if (err.code === "not_supported") {
this._unsupported["cloud.alexa"] = true;
this.requestUpdate("_unsupported");
this._unsupported = { ...this._unsupported, "cloud.alexa": true };
}
}
}
@@ -127,7 +112,6 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
this._cloudStatus.prefs.alexa_enabled === true;
const showAssistants = [...Object.keys(voiceAssistants)];
const uiAssistants = [...showAssistants];
const alexaManual =
alexaEnabled &&
@@ -145,20 +129,12 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
showAssistants.indexOf("cloud.google_assistant"),
1
);
uiAssistants.splice(showAssistants.indexOf("cloud.google_assistant"), 1);
} else if (googleManual) {
uiAssistants.splice(uiAssistants.indexOf("cloud.google_assistant"), 1);
}
if (!alexaEnabled) {
showAssistants.splice(showAssistants.indexOf("cloud.alexa"), 1);
uiAssistants.splice(uiAssistants.indexOf("cloud.alexa"), 1);
} else if (alexaManual) {
uiAssistants.splice(uiAssistants.indexOf("cloud.alexa"), 1);
}
const uiExposed = uiAssistants.some((key) => this.exposed[key]);
let manFilterFuncs:
| {
google: EntityDomainFilterFunc;
@@ -177,216 +153,97 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
const manExposedGoogle =
googleManual && manFilterFuncs!.google(this.entityId);
const anyExposed = uiExposed || manExposedAlexa || manExposedGoogle;
return html`
<ha-md-list-item>
<h3 slot="headline">
${this.hass.localize("ui.dialogs.voice-settings.expose_header")}
</h3>
<ha-switch
slot="end"
@change=${this._toggleAll}
.assistants=${uiAssistants}
.checked=${anyExposed}
></ha-switch>
</ha-md-list-item>
${anyExposed
? showAssistants.map((key) => {
const supported = !this._unsupported[key];
${showAssistants.map((key) => {
const supported = !this._unsupported[key];
const exposed =
alexaManual && key === "cloud.alexa"
? manExposedAlexa
: googleManual && key === "cloud.google_assistant"
? manExposedGoogle
: this.exposed[key];
const exposed =
alexaManual && key === "cloud.alexa"
? manExposedAlexa
: googleManual && key === "cloud.google_assistant"
? manExposedGoogle
: this.exposed[key];
const manualConfig =
(alexaManual && key === "cloud.alexa") ||
(googleManual && key === "cloud.google_assistant");
const manualConfig =
(alexaManual && key === "cloud.alexa") ||
(googleManual && key === "cloud.google_assistant");
const support2fa =
key === "cloud.google_assistant" &&
!googleManual &&
supported &&
this._googleEntity?.might_2fa;
const hasSettings = supported && !manualConfig;
return html`
<ha-md-list-item>
<voice-assistant-brand-icon
slot="start"
.voiceAssistantId=${key}
>
</voice-assistant-brand-icon>
<span slot="headline">${voiceAssistants[key].name}</span>
${!supported
? html`<div slot="supporting-text" class="unsupported">
<ha-svg-icon .path=${mdiAlertCircle}></ha-svg-icon>
const aliasCount =
key === "conversation"
? this.entry
? this.entry.aliases.filter(Boolean).length
: undefined
: key === "cloud.google_assistant"
? (this._googleEntity?.aliases?.filter(Boolean).length ?? 0)
: undefined;
return html`
<ha-md-list-item>
<voice-assistant-brand-icon slot="start" .voiceAssistantId=${key}>
</voice-assistant-brand-icon>
<span slot="headline">${voiceAssistants[key].name}</span>
${!supported
? html`<div slot="supporting-text" class="unsupported">
<ha-svg-icon .path=${mdiAlertCircle}></ha-svg-icon>
${this.hass.localize("ui.dialogs.voice-settings.unsupported")}
</div>`
: manualConfig
? html`
<div slot="supporting-text">
${this.hass.localize(
"ui.dialogs.voice-settings.unsupported"
"ui.dialogs.voice-settings.manual_config"
)}
</div>
`
: aliasCount
? html`<div slot="supporting-text">
${this.hass.localize(
"ui.dialogs.voice-settings.aliases_count",
{ count: aliasCount }
)}
</div>`
: nothing}
${manualConfig
? html`
<div slot="supporting-text">
${this.hass.localize(
"ui.dialogs.voice-settings.manual_config"
)}
</div>
`
: nothing}
${support2fa
? html`
<ha-checkbox
slot="supporting-text"
.checked=${!this._googleEntity!.disable_2fa}
@change=${this._2faChanged}
>
${this.hass.localize(
"ui.dialogs.voice-settings.ask_pin"
)}
</ha-checkbox>
`
: nothing}
<ha-switch
slot="end"
.assistant=${key}
@change=${this._toggleAssistant}
.disabled=${manualConfig || (!exposed && !supported)}
.checked=${exposed}
></ha-switch>
</ha-md-list-item>
`;
})
: nothing}
<h3 class="header">
${this.hass.localize("ui.dialogs.voice-settings.aliases_header")}
</h3>
<p class="description">
${this.hass.localize("ui.dialogs.voice-settings.aliases_description")}
</p>
${!this.entry
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.dialogs.voice-settings.aliases_no_unique_id",
{
faq_link: html`<a
href=${documentationUrl(this.hass, "/faq/unique_id")}
target="_blank"
rel="noreferrer"
>${this.hass.localize("ui.dialogs.entity_registry.faq")}</a
>`,
}
)}
</ha-alert>`
: html`
<ha-md-list-item>
<span slot="headline">
${this.hass.states[this.entityId]
? computeStateName(this.hass.states[this.entityId])
: this.entityId}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.dialogs.voice-settings.entity_name_alias_description"
)}
</span>
<div slot="end" class="trailing">
${hasSettings
? html`<ha-icon-button
.path=${mdiCog}
.label=${this.hass.localize(
"ui.dialogs.voice-settings.edit_settings",
{ assistant: voiceAssistants[key].name }
)}
.assistant=${key}
@click=${this._editAssistant}
></ha-icon-button>`
: nothing}
<ha-switch
slot="end"
.checked=${(this._aliases ?? this.entry.aliases).includes(null)}
@change=${this._toggleEntityNameAlias}
.assistant=${key}
@change=${this._toggleAssistant}
.disabled=${manualConfig || (!exposed && !supported)}
.checked=${exposed}
></ha-switch>
</ha-md-list-item>
<ha-aliases-editor
.aliases=${(this._aliases ?? this.entry.aliases).filter(
(a): a is string => a !== null
)}
sortable
@value-changed=${this._aliasesChanged}
></ha-aliases-editor>
`}
</div>
</ha-md-list-item>
`;
})}
`;
}
private async _toggleEntityNameAlias(ev) {
const enabled = ev.target.checked;
const currentAliases = this._aliases ?? this.entry?.aliases ?? [];
if (enabled) {
this._aliases = [null, ...currentAliases.filter((a) => a !== null)];
} else {
this._aliases = currentAliases.filter((a): a is string => a !== null);
}
await this._saveAliases();
}
private _aliasesChanged(ev) {
const currentAliases = this._aliases ?? this.entry?.aliases ?? [];
const hasNull = currentAliases.includes(null);
const nullAliases: (string | null)[] = hasNull ? [null] : [];
const newStringAliases: string[] = ev.detail.value;
this._aliases = [...nullAliases, ...newStringAliases];
this._saveAliases();
}
private async _2faChanged(ev) {
try {
await updateCloudGoogleEntityConfig(
this.hass,
this.entityId,
!ev.target.checked
);
} catch (_err) {
ev.target.checked = !ev.target.checked;
}
}
private async _saveAliases() {
if (!this._aliases) {
return;
}
const hasNull = this._aliases.includes(null);
const nullAliases: null[] = hasNull ? [null] : [];
const stringAliases = this._aliases
.filter((a): a is string => a !== null)
.map((alias) => alias.trim())
.filter((alias) => alias);
const result = await updateEntityRegistryEntry(this.hass, this.entityId, {
aliases: [...nullAliases, ...stringAliases],
});
fireEvent(this, "entity-entry-updated", result.entity_entry);
private _editAssistant(ev) {
fireEvent(this, "edit-assistant", { assistant: ev.target.assistant });
}
private async _toggleAssistant(ev) {
exposeEntities(
this.hass,
[ev.target.assistant],
[this.entityId],
ev.target.checked
);
if (this.entry) {
const entry = await getExtendedEntityRegistryEntry(
this.hass,
this.entityId
);
fireEvent(this, "entity-entry-updated", entry);
}
fireEvent(this, "exposed-entities-changed");
}
ev.stopPropagation();
const assistant: string = ev.target.assistant;
const checked: boolean = ev.target.checked;
private async _toggleAll(ev) {
const expose = ev.target.checked;
exposeEntities(this.hass, [assistant], [this.entityId], checked);
fireEvent(this, "exposed-changed", {
value: { ...this.exposed, [assistant]: checked },
});
const assistants = expose
? ev.target.assistants.filter((key) => !this._unsupported[key])
: ev.target.assistants;
exposeEntities(this.hass, assistants, [this.entityId], ev.target.checked);
if (this.entry) {
const entry = await getExtendedEntityRegistryEntry(
this.hass,
@@ -403,7 +260,7 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
css`
:host {
display: block;
margin: 32px;
margin: var(--ha-space-8);
margin-top: 0;
}
ha-md-list-item {
@@ -411,19 +268,10 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
}
img {
height: 32px;
width: 32px;
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
}
ha-aliases-editor {
display: block;
}
ha-alert {
display: block;
margin-top: 16px;
.trailing {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.unsupported {
display: flex;
@@ -432,21 +280,10 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
.unsupported ha-svg-icon {
color: var(--error-color);
--mdc-icon-size: 16px;
margin-right: 4px;
margin-inline-end: 4px;
margin-right: var(--ha-space-1);
margin-inline-end: var(--ha-space-1);
margin-inline-start: initial;
}
.header {
margin-top: 8px;
margin-bottom: 4px;
}
.description {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-condensed);
margin-top: 0;
margin-bottom: 16px;
}
`,
];
}
@@ -454,15 +291,11 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
declare global {
interface HTMLElementTagNameMap {
"entity-registry-settings": EntityRegistrySettings;
"entity-voice-settings": EntityVoiceSettings;
}
interface HASSDomEvents {
"entity-entry-updated": ExtEntityRegistryEntry;
}
}
declare global {
interface HTMLElementTagNameMap {
"entity-voice-settings": EntityVoiceSettings;
"edit-assistant": { assistant: string };
"exposed-changed": { value: ExposeEntitySettings };
}
}
@@ -0,0 +1,176 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/ha-aliases-editor";
import "../../../components/ha-md-list-item";
import "../../../components/ha-switch";
import "../../../components/input/ha-input";
import { updateCloudGoogleEntityConfig } from "../../../data/cloud";
import type { GoogleEntity } from "../../../data/google_assistant";
import { fetchCloudGoogleEntity } from "../../../data/google_assistant";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@customElement("google-entity-voice-settings")
export class GoogleEntityVoiceSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@state() private _entity?: GoogleEntity;
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("entityId") && this.entityId) {
this._fetchEntity();
}
}
private async _fetchEntity() {
try {
const entity = await fetchCloudGoogleEntity(this.hass, this.entityId);
if (entity.aliases) {
entity.aliases = entity.aliases.filter(Boolean);
}
this._entity = entity;
} catch (_err) {
this._entity = undefined;
}
}
protected render() {
if (!this._entity) {
return nothing;
}
const defaultName = this.hass.states[this.entityId]
? computeStateName(this.hass.states[this.entityId])
: this.entityId;
return html`
<ha-input
.label=${this.hass.localize("ui.dialogs.voice-settings.name")}
.hint=${this.hass.localize(
"ui.dialogs.voice-settings.name_description"
)}
with-clear
.value=${this._entity.name ?? ""}
.placeholder=${defaultName}
@change=${this._nameChanged}
></ha-input>
<h4 class="header">
${this.hass.localize("ui.dialogs.voice-settings.aliases")}
</h4>
<ha-aliases-editor
.aliases=${this._entity.aliases ?? []}
@value-changed=${this._aliasesChanged}
></ha-aliases-editor>
${this._entity.might_2fa
? html`
<wa-divider></wa-divider>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize("ui.dialogs.voice-settings.ask_pin")}
</span>
<ha-switch
slot="end"
.checked=${!this._entity.disable_2fa}
@change=${this._2faChanged}
></ha-switch>
</ha-md-list-item>
`
: nothing}
`;
}
private async _nameChanged(ev) {
if (!this._entity) {
return;
}
const value = ev.target.value?.trim() || null;
if ((this._entity.name ?? null) === value) {
return;
}
const previous = this._entity.name ?? null;
this._entity = { ...this._entity, name: value };
try {
await updateCloudGoogleEntityConfig(this.hass, this.entityId, {
name: value,
});
} catch (_err) {
this._entity = { ...this._entity, name: previous };
}
}
private async _aliasesChanged(ev) {
if (!this._entity) {
return;
}
const aliases = ev.detail.value as string[];
const previous = this._entity.aliases ?? null;
this._entity = { ...this._entity, aliases };
const stringAliases = aliases
.map((alias) => alias.trim())
.filter((alias) => alias);
try {
await updateCloudGoogleEntityConfig(this.hass, this.entityId, {
aliases: stringAliases,
});
} catch (_err) {
this._entity = { ...this._entity, aliases: previous };
}
}
private async _2faChanged(ev) {
if (!this._entity) {
return;
}
const disable_2fa = !ev.target.checked;
this._entity = { ...this._entity, disable_2fa };
try {
await updateCloudGoogleEntityConfig(this.hass, this.entityId, {
disable_2fa,
});
} catch (_err) {
this._entity = { ...this._entity, disable_2fa: !disable_2fa };
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
margin: 0 var(--ha-space-8) var(--ha-space-8);
}
ha-input {
display: block;
width: 100%;
}
ha-aliases-editor {
display: block;
}
.header {
margin-top: var(--ha-space-2);
margin-bottom: var(--ha-space-1);
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
}
wa-divider {
margin: var(--ha-space-2) 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"google-entity-voice-settings": GoogleEntityVoiceSettings;
}
}
@@ -104,6 +104,8 @@ export class VoiceAssistantsExpose extends LitElement {
string[] | undefined
>;
@state() private _googleAliases?: Record<string, string[]>;
@storage({
key: "voice-expose-table-sort",
state: false,
@@ -158,7 +160,8 @@ export class VoiceAssistantsExpose extends LitElement {
| undefined,
_language: string,
localize: LocalizeFunc,
entitiesToCheck?: any[]
entitiesToCheck?: any[],
googleAliases?: Record<string, string[]>
): DataTableColumnContainer => ({
icon: {
title: "",
@@ -199,9 +202,15 @@ export class VoiceAssistantsExpose extends LitElement {
sortable: true,
filterable: true,
template: (entry) => {
const aliases = entry.aliases.filter(
const registryAliases = entry.aliases.filter(
(a: string | null) => a !== null
);
const aliases = [
...new Set([
...registryAliases,
...(googleAliases?.[entry.entity_id] ?? []),
]),
];
return aliases.length === 0
? "-"
: aliases.length === 1
@@ -457,6 +466,14 @@ export class VoiceAssistantsExpose extends LitElement {
// TODO add supported entity for assist
conversation: undefined,
};
this._googleAliases = googleEntities
? Object.fromEntries(
googleEntities.map((entity) => [
entity.entity_id,
(entity.aliases ?? []).filter(Boolean),
])
)
: undefined;
}
public willUpdate(changedProperties: PropertyValues): void {
@@ -503,7 +520,8 @@ export class VoiceAssistantsExpose extends LitElement {
this._supportedEntities,
this.hass.language,
this.hass.localize,
filteredEntities
filteredEntities,
this._googleAliases
)}
.data=${filteredEntities}
.searchLabel=${this.hass.localize(
@@ -708,6 +726,9 @@ export class VoiceAssistantsExpose extends LitElement {
exposedEntitiesChanged: () => {
fireEvent(this, "exposed-entities-changed");
},
entityEntryUpdated: (entry) => {
this._extEntities = { ...this._extEntities, [entityId]: entry };
},
});
}
@@ -7,6 +7,7 @@ export interface VoiceSettingsDialogParams {
exposed: ExposeEntitySettings;
extEntityReg?: ExtEntityRegistryEntry;
exposedEntitiesChanged?: () => void;
entityEntryUpdated?: (entry: ExtEntityRegistryEntry) => void;
}
export const loadVoiceSettingsDialog = () => import("./dialog-voice-settings");
@@ -0,0 +1,47 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../types";
import "./alexa-entity-voice-settings";
import "./assist-entity-voice-settings";
import "./google-entity-voice-settings";
@customElement("voice-assistant-settings")
export class VoiceAssistantSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@property({ attribute: false }) public assistant!: string;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry;
protected render() {
switch (this.assistant) {
case "cloud.google_assistant":
return html`<google-entity-voice-settings
.hass=${this.hass}
.entityId=${this.entityId}
></google-entity-voice-settings>`;
case "cloud.alexa":
return html`<alexa-entity-voice-settings
.hass=${this.hass}
.entityId=${this.entityId}
></alexa-entity-voice-settings>`;
case "conversation":
return html`<assist-entity-voice-settings
.hass=${this.hass}
.entityId=${this.entityId}
.entry=${this.entry}
></assist-entity-voice-settings>`;
default:
return nothing;
}
}
}
declare global {
interface HTMLElementTagNameMap {
"voice-assistant-settings": VoiceAssistantSettings;
}
}
+2
View File
@@ -0,0 +1,2 @@
export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard";
export const DEFAULT_POWER_COLLECTION_KEY = "energy_dashboard_now";
+1 -1
View File
@@ -16,7 +16,7 @@ import "../lovelace/hui-root";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
import { DEFAULT_POWER_COLLECTION_KEY } from "../../data/energy";
import { DEFAULT_POWER_COLLECTION_KEY } from "./constants";
@customElement("ha-panel-energy")
class PanelEnergy extends LitElement {
@@ -1,8 +1,6 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import {
DEFAULT_ENERGY_COLLECTION_KEY,
DEFAULT_POWER_COLLECTION_KEY,
EMPTY_PREFERENCES,
getEnergyDataCollection,
} from "../../../data/energy";
@@ -13,6 +11,10 @@ import type { LovelaceStrategyViewConfig } from "../../../data/lovelace/config/v
import type { LocalizeKeys } from "../../../common/translations/localize";
import type { HomeAssistant } from "../../../types";
import type { LovelaceStrategyDependency } from "../../lovelace/strategies/types";
import {
DEFAULT_ENERGY_COLLECTION_KEY,
DEFAULT_POWER_COLLECTION_KEY,
} from "../constants";
import type { EnergyViewPath } from "./energy-cards";
import {
hasDeviceConsumption,
@@ -158,9 +160,6 @@ async function fetchEnergyPrefs(
): Promise<EnergyPreferences> {
const collection = getEnergyDataCollection(hass, {
key: defaultCollection || DEFAULT_ENERGY_COLLECTION_KEY,
// When landing directly on the "Now" view this warms its real-time
// collection, so it must be created with midnight rollover too.
midnightRollover: defaultCollection === DEFAULT_POWER_COLLECTION_KEY,
});
return await new Promise<EnergyPreferences>((resolve) => {
@@ -1,12 +1,10 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import {
DEFAULT_ENERGY_COLLECTION_KEY,
getEnergyDataCollection,
} from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy";
import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyDependency } from "../../lovelace/strategies/types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { hasWaterSource, isEnergyCardVisible } from "./energy-cards";
@@ -1,12 +1,10 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import {
DEFAULT_ENERGY_COLLECTION_KEY,
getEnergyDataCollection,
} from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { isEnergyCardVisible } from "./energy-cards";
import { shouldShowFloorsAndAreas } from "./show-floors-and-areas";
@@ -1,11 +1,9 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import {
DEFAULT_ENERGY_COLLECTION_KEY,
getEnergyDataCollection,
} from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy";
import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { hasGasSource, isEnergyCardVisible } from "./energy-cards";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
@@ -1,11 +1,9 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import {
DEFAULT_ENERGY_COLLECTION_KEY,
getEnergyDataCollection,
} from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import {
hasGasRateSource,
@@ -34,8 +32,6 @@ export class PowerViewStrategy extends ReactiveElement {
const energyCollection = getEnergyDataCollection(hass, {
key: collectionKey,
// The "Now" view is real-time; roll its day period over at midnight.
midnightRollover: true,
});
if (!energyCollection.prefs) {
await energyCollection.refresh();
@@ -1,13 +1,11 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import {
DEFAULT_ENERGY_COLLECTION_KEY,
getEnergyDataCollection,
} from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { LovelaceStrategyDependency } from "../../lovelace/strategies/types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import {
hasWaterDevices,
@@ -50,6 +50,7 @@ export class HomeFavoritesEditor extends LitElement {
</ha-sortable>
<ha-entity-picker
add-button
.hass=${this.hass}
.addButtonLabel=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
)}
@@ -1,20 +0,0 @@
import type { EntityBadgeConfig } from "../badges/types";
import type { BadgeSuggestion, BadgeSuggestionProvider } from "./types";
export const entityBadgeSuggestions: BadgeSuggestionProvider<EntityBadgeConfig> =
{
getEntitySuggestion(hass, entityId) {
const suggestions: BadgeSuggestion<EntityBadgeConfig>[] = [
{
config: { type: "entity", entity: entityId },
},
{
label: hass.localize(
"ui.panel.lovelace.editor.badge_picker.with_name"
),
config: { type: "entity", entity: entityId, show_name: true },
},
];
return suggestions;
},
};
@@ -1,45 +0,0 @@
import { ensureArray } from "../../../common/array/ensure-array";
import { customBadges } from "../../../data/lovelace_custom_cards";
import type { HomeAssistant } from "../../../types";
import { BADGE_SUGGESTION_PROVIDERS } from "./registry";
import type { BadgeSuggestion } from "./types";
export type { BadgeSuggestion, BadgeSuggestionProvider } from "./types";
export { BADGE_SUGGESTION_PROVIDERS } from "./registry";
export interface BadgeSuggestions {
core: BadgeSuggestion[];
custom: BadgeSuggestion[];
}
export const generateBadgeSuggestions = (
hass: HomeAssistant,
entityId: string | undefined
): BadgeSuggestions => {
if (!entityId || hass.states[entityId] === undefined) {
return { core: [], custom: [] };
}
const core = Object.values(BADGE_SUGGESTION_PROVIDERS).flatMap((provider) => {
try {
return ensureArray(provider.getEntitySuggestion(hass, entityId)) ?? [];
} catch (err) {
// eslint-disable-next-line no-console
console.error("Badge suggestion provider threw:", err);
return [];
}
});
const custom = customBadges.flatMap((badge) => {
if (!badge.getEntitySuggestion) return [];
try {
return ensureArray(badge.getEntitySuggestion(hass, entityId)) ?? [];
} catch (err) {
// eslint-disable-next-line no-console
console.error(
`Custom badge "${badge.type}" getEntitySuggestion threw:`,
err
);
return [];
}
});
return { core, custom };
};
@@ -1,9 +0,0 @@
import { entityBadgeSuggestions } from "./hui-entity-badge-suggestions";
import type { BadgeSuggestionProvider } from "./types";
export const BADGE_SUGGESTION_PROVIDERS: Record<
string,
BadgeSuggestionProvider
> = {
entity: entityBadgeSuggestions,
};

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