diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index b9193713405a..1ff067e3c815 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -55,7 +55,7 @@ jobs:
script/release
- name: Upload release assets
- uses: softprops/action-gh-release@v2.0.9
+ uses: softprops/action-gh-release@v2.1.0
with:
files: |
dist/*.whl
@@ -74,7 +74,7 @@ jobs:
echo "home-assistant-frontend==$version" > ./requirements.txt
- name: Build wheels
- uses: home-assistant/wheels@2024.07.1
+ uses: home-assistant/wheels@2024.11.0
with:
abi: cp312
tag: musllinux_1_2
diff --git a/build-scripts/README.md b/build-scripts/README.md
index 0a6963f7e926..8e7933d2f8a9 100644
--- a/build-scripts/README.md
+++ b/build-scripts/README.md
@@ -15,7 +15,7 @@ The Home Assistant build pipeline contains various steps to prepare a build.
Currently in Home Assistant we use a bundler to convert TypeScript, CSS and JSON files to JavaScript files that the browser understands.
-We currently rely on Webpack but also have experimental Rollup support. Both of these programs bundle the converted files in both production and development.
+We currently rely on Webpack. Both of these programs bundle the converted files in both production and development.
For development, bundling is optional. We just want to get the right files in the browser.
diff --git a/build-scripts/bundle.cjs b/build-scripts/bundle.cjs
index 84e1490f995b..6b2f308b3d3a 100644
--- a/build-scripts/bundle.cjs
+++ b/build-scripts/bundle.cjs
@@ -226,13 +226,12 @@ module.exports.config = {
return {
name: "frontend" + nameSuffix(latestBuild),
entry: {
- "service-worker":
- !env.useRollup() && !latestBuild
- ? {
- import: "./src/entrypoints/service-worker.ts",
- layer: "sw",
- }
- : "./src/entrypoints/service-worker.ts",
+ "service-worker": !latestBuild
+ ? {
+ import: "./src/entrypoints/service-worker.ts",
+ layer: "sw",
+ }
+ : "./src/entrypoints/service-worker.ts",
app: "./src/entrypoints/app.ts",
authorize: "./src/entrypoints/authorize.ts",
onboarding: "./src/entrypoints/onboarding.ts",
diff --git a/build-scripts/env.cjs b/build-scripts/env.cjs
index 6f208b779b33..6f2bb4be589b 100644
--- a/build-scripts/env.cjs
+++ b/build-scripts/env.cjs
@@ -3,9 +3,6 @@ const path = require("path");
const paths = require("./paths.cjs");
module.exports = {
- useRollup() {
- return process.env.ROLLUP === "1";
- },
useWDS() {
return process.env.WDS === "1";
},
diff --git a/build-scripts/gulp/app.js b/build-scripts/gulp/app.js
index 3b4343a49530..7d2264eba793 100644
--- a/build-scripts/gulp/app.js
+++ b/build-scripts/gulp/app.js
@@ -6,7 +6,6 @@ import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./locale-data.js";
-import "./rollup.js";
import "./service-worker.js";
import "./translations.js";
import "./wds.js";
@@ -27,11 +26,7 @@ gulp.task(
"build-locale-data"
),
"copy-static-app",
- env.useWDS()
- ? "wds-watch-app"
- : env.useRollup()
- ? "rollup-watch-app"
- : "webpack-watch-app"
+ env.useWDS() ? "wds-watch-app" : "webpack-watch-app"
)
);
@@ -44,7 +39,7 @@ gulp.task(
"clean",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-app",
- env.useRollup() ? "rollup-prod-app" : "webpack-prod-app",
+ "webpack-prod-app",
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
// Don't compress running tests
...(env.isTestBuild() ? [] : ["compress-app"])
diff --git a/build-scripts/gulp/cast.js b/build-scripts/gulp/cast.js
index adde0f212c98..d883deac5d83 100644
--- a/build-scripts/gulp/cast.js
+++ b/build-scripts/gulp/cast.js
@@ -1,9 +1,7 @@
import gulp from "gulp";
-import env from "../env.cjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
-import "./rollup.js";
import "./service-worker.js";
import "./translations.js";
import "./webpack.js";
@@ -19,7 +17,7 @@ gulp.task(
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-cast",
"gen-pages-cast-dev",
- env.useRollup() ? "rollup-dev-server-cast" : "webpack-dev-server-cast"
+ "webpack-dev-server-cast"
)
);
@@ -33,7 +31,7 @@ gulp.task(
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-cast",
- env.useRollup() ? "rollup-prod-cast" : "webpack-prod-cast",
+ "webpack-prod-cast",
"gen-pages-cast-prod"
)
);
diff --git a/build-scripts/gulp/demo.js b/build-scripts/gulp/demo.js
index 8ed7a65953c3..27ba3439080d 100644
--- a/build-scripts/gulp/demo.js
+++ b/build-scripts/gulp/demo.js
@@ -1,10 +1,8 @@
import gulp from "gulp";
-import env from "../env.cjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
-import "./rollup.js";
import "./service-worker.js";
import "./translations.js";
import "./webpack.js";
@@ -24,7 +22,7 @@ gulp.task(
"build-locale-data"
),
"copy-static-demo",
- env.useRollup() ? "rollup-dev-server-demo" : "webpack-dev-server-demo"
+ "webpack-dev-server-demo"
)
);
@@ -39,7 +37,7 @@ gulp.task(
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-demo",
- env.useRollup() ? "rollup-prod-demo" : "webpack-prod-demo",
+ "webpack-prod-demo",
"gen-pages-demo-prod"
)
);
diff --git a/build-scripts/gulp/download-translations.js b/build-scripts/gulp/download-translations.js
index b9f2c9758397..b12eb228f091 100644
--- a/build-scripts/gulp/download-translations.js
+++ b/build-scripts/gulp/download-translations.js
@@ -127,6 +127,7 @@ gulp.task("fetch-lokalise", async function () {
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
+ filter_data: ["verified"],
})
.then((download) => fetch(download.bundle_url))
.then((response) => {
diff --git a/build-scripts/gulp/entry-html.js b/build-scripts/gulp/entry-html.js
index 86e2e22ee09e..d8bf587f6c01 100644
--- a/build-scripts/gulp/entry-html.js
+++ b/build-scripts/gulp/entry-html.js
@@ -56,7 +56,6 @@ const getCommonTemplateVars = () => {
{ ignorePatch: true, allowHigherVersions: true }
);
return {
- useRollup: env.useRollup(),
useWDS: env.useWDS(),
modernRegex: compileRegex(browserRegexes.concat(haMacOSRegex)).toString(),
};
diff --git a/build-scripts/gulp/gallery.js b/build-scripts/gulp/gallery.js
index efbb3f9ea5e1..16255b27cad8 100644
--- a/build-scripts/gulp/gallery.js
+++ b/build-scripts/gulp/gallery.js
@@ -4,13 +4,11 @@ import gulp from "gulp";
import yaml from "js-yaml";
import { marked } from "marked";
import path from "path";
-import env from "../env.cjs";
import paths from "../paths.cjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
-import "./rollup.js";
import "./service-worker.js";
import "./translations.js";
import "./webpack.js";
@@ -158,9 +156,7 @@ gulp.task(
"copy-static-gallery",
"gen-pages-gallery-dev",
gulp.parallel(
- env.useRollup()
- ? "rollup-dev-server-gallery"
- : "webpack-dev-server-gallery",
+ "webpack-dev-server-gallery",
async function watchMarkdownFiles() {
gulp.watch(
[
@@ -189,7 +185,7 @@ gulp.task(
"gather-gallery-pages"
),
"copy-static-gallery",
- env.useRollup() ? "rollup-prod-gallery" : "webpack-prod-gallery",
+ "webpack-prod-gallery",
"gen-pages-gallery-prod"
)
);
diff --git a/build-scripts/gulp/gather-static.js b/build-scripts/gulp/gather-static.js
index c02511abff2d..653e863dcde7 100644
--- a/build-scripts/gulp/gather-static.js
+++ b/build-scripts/gulp/gather-static.js
@@ -4,7 +4,6 @@ import fs from "fs-extra";
import gulp from "gulp";
import path from "path";
import paths from "../paths.cjs";
-import env from "../env.cjs";
const npmPath = (...parts) =>
path.resolve(paths.polymer_dir, "node_modules", ...parts);
@@ -69,9 +68,6 @@ function copyPolyfills(staticDir) {
}
function copyLoaderJS(staticDir) {
- if (!env.useRollup()) {
- return;
- }
const staticPath = genStaticPath(staticDir);
copyFileDir(npmPath("systemjs/dist/s.min.js"), staticPath("js"));
copyFileDir(npmPath("systemjs/dist/s.min.js.map"), staticPath("js"));
diff --git a/build-scripts/gulp/hassio.js b/build-scripts/gulp/hassio.js
index db97991e4b38..a177100f7763 100644
--- a/build-scripts/gulp/hassio.js
+++ b/build-scripts/gulp/hassio.js
@@ -5,7 +5,6 @@ import "./compress.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
-import "./rollup.js";
import "./translations.js";
import "./webpack.js";
@@ -22,7 +21,7 @@ gulp.task(
"copy-translations-supervisor",
"build-locale-data",
"copy-static-supervisor",
- env.useRollup() ? "rollup-watch-hassio" : "webpack-watch-hassio"
+ "webpack-watch-hassio"
)
);
@@ -38,7 +37,7 @@ gulp.task(
"copy-translations-supervisor",
"build-locale-data",
"copy-static-supervisor",
- env.useRollup() ? "rollup-prod-hassio" : "webpack-prod-hassio",
+ "webpack-prod-hassio",
"gen-pages-hassio-prod",
...// Don't compress running tests
(env.isTestBuild() ? [] : ["compress-hassio"])
diff --git a/build-scripts/gulp/rollup.js b/build-scripts/gulp/rollup.js
deleted file mode 100644
index 83810abbe215..000000000000
--- a/build-scripts/gulp/rollup.js
+++ /dev/null
@@ -1,147 +0,0 @@
-// Tasks to run Rollup
-
-import log from "fancy-log";
-import gulp from "gulp";
-import http from "http";
-import open from "open";
-import path from "path";
-import { rollup } from "rollup";
-import handler from "serve-handler";
-import paths from "../paths.cjs";
-import rollupConfig from "../rollup.cjs";
-
-const bothBuilds = (createConfigFunc, params) =>
- gulp.series(
- async function buildLatest() {
- await buildRollup(
- createConfigFunc({
- ...params,
- latestBuild: true,
- })
- );
- },
- async function buildES5() {
- await buildRollup(
- createConfigFunc({
- ...params,
- latestBuild: false,
- })
- );
- }
- );
-
-function createServer(serveOptions) {
- const server = http.createServer((request, response) =>
- handler(request, response, {
- public: serveOptions.root,
- })
- );
-
- server.listen(
- serveOptions.port,
- serveOptions.networkAccess ? "0.0.0.0" : undefined,
- () => {
- log.info(`Available at http://localhost:${serveOptions.port}`);
- open(`http://localhost:${serveOptions.port}`);
- }
- );
-}
-
-function watchRollup(createConfig, extraWatchSrc = [], serveOptions = null) {
- const { inputOptions, outputOptions } = createConfig({
- isProdBuild: false,
- latestBuild: true,
- });
-
- const watcher = rollup.watch({
- ...inputOptions,
- output: [outputOptions],
- watch: {
- include: ["src/**"] + extraWatchSrc,
- },
- });
-
- let startedHttp = false;
-
- watcher.on("event", (event) => {
- if (event.code === "BUNDLE_END") {
- log(`Build done @ ${new Date().toLocaleTimeString()}`);
- } else if (event.code === "ERROR") {
- log.error(event.error);
- } else if (event.code === "END") {
- if (startedHttp || !serveOptions) {
- return;
- }
- startedHttp = true;
- createServer(serveOptions);
- }
- });
-
- gulp.watch(
- path.join(paths.translations_src, "en.json"),
- gulp.series("build-translations", "copy-translations-app")
- );
-}
-
-async function buildRollup(config) {
- const bundle = await rollup.rollup(config.inputOptions);
- await bundle.write(config.outputOptions);
-}
-
-gulp.task("rollup-watch-app", () => {
- watchRollup(rollupConfig.createAppConfig);
-});
-
-gulp.task("rollup-watch-hassio", () => {
- watchRollup(rollupConfig.createHassioConfig, ["hassio/src/**"]);
-});
-
-gulp.task("rollup-dev-server-demo", () => {
- watchRollup(rollupConfig.createDemoConfig, ["demo/src/**"], {
- root: paths.demo_output_root,
- port: 8090,
- });
-});
-
-gulp.task("rollup-dev-server-cast", () => {
- watchRollup(rollupConfig.createCastConfig, ["cast/src/**"], {
- root: paths.cast_output_root,
- port: 8080,
- networkAccess: true,
- });
-});
-
-gulp.task("rollup-dev-server-gallery", () => {
- watchRollup(rollupConfig.createGalleryConfig, ["gallery/src/**"], {
- root: paths.gallery_output_root,
- port: 8100,
- });
-});
-
-gulp.task(
- "rollup-prod-app",
- bothBuilds(rollupConfig.createAppConfig, { isProdBuild: true })
-);
-
-gulp.task(
- "rollup-prod-demo",
- bothBuilds(rollupConfig.createDemoConfig, { isProdBuild: true })
-);
-
-gulp.task(
- "rollup-prod-cast",
- bothBuilds(rollupConfig.createCastConfig, { isProdBuild: true })
-);
-
-gulp.task("rollup-prod-hassio", () =>
- bothBuilds(rollupConfig.createHassioConfig, { isProdBuild: true })
-);
-
-gulp.task("rollup-prod-gallery", () =>
- buildRollup(
- rollupConfig.createGalleryConfig({
- isProdBuild: true,
- latestBuild: true,
- })
- )
-);
diff --git a/build-scripts/rollup-plugins/dont-hash-plugin.cjs b/build-scripts/rollup-plugins/dont-hash-plugin.cjs
deleted file mode 100644
index 89082b90c21a..000000000000
--- a/build-scripts/rollup-plugins/dont-hash-plugin.cjs
+++ /dev/null
@@ -1,14 +0,0 @@
-module.exports = function (opts = {}) {
- const dontHash = opts.dontHash || new Set();
-
- return {
- name: "dont-hash",
- renderChunk(_code, chunk, _options) {
- if (!chunk.isEntry || !dontHash.has(chunk.name)) {
- return null;
- }
- chunk.fileName = `${chunk.name}.js`;
- return null;
- },
- };
-};
diff --git a/build-scripts/rollup-plugins/ignore-plugin.cjs b/build-scripts/rollup-plugins/ignore-plugin.cjs
deleted file mode 100644
index 5819958092ca..000000000000
--- a/build-scripts/rollup-plugins/ignore-plugin.cjs
+++ /dev/null
@@ -1,24 +0,0 @@
-module.exports = function (userOptions = {}) {
- // Files need to be absolute paths.
- // This only works if the file has no exports
- // and only is imported for its side effects
- const files = userOptions.files || [];
-
- if (files.length === 0) {
- return {
- name: "ignore",
- };
- }
-
- return {
- name: "ignore",
-
- load(id) {
- return files.some((toIgnorePath) => id.startsWith(toIgnorePath))
- ? {
- code: "",
- }
- : null;
- },
- };
-};
diff --git a/build-scripts/rollup-plugins/manifest-plugin.cjs b/build-scripts/rollup-plugins/manifest-plugin.cjs
deleted file mode 100644
index bf4bbaac0539..000000000000
--- a/build-scripts/rollup-plugins/manifest-plugin.cjs
+++ /dev/null
@@ -1,34 +0,0 @@
-const url = require("url");
-
-const defaultOptions = {
- publicPath: "",
-};
-
-module.exports = function (userOptions = {}) {
- const options = { ...defaultOptions, ...userOptions };
-
- return {
- name: "manifest",
- generateBundle(outputOptions, bundle) {
- const manifest = {};
-
- for (const chunk of Object.values(bundle)) {
- if (!chunk.isEntry) {
- continue;
- }
- // Add js extension to mimic Webpack manifest.
- manifest[`${chunk.name}.js`] = url.resolve(
- options.publicPath,
- chunk.fileName
- );
- }
-
- this.emitFile({
- type: "asset",
- source: JSON.stringify(manifest, undefined, 2),
- name: "manifest.json",
- fileName: "manifest.json",
- });
- },
- };
-};
diff --git a/build-scripts/rollup-plugins/worker-plugin.cjs b/build-scripts/rollup-plugins/worker-plugin.cjs
deleted file mode 100644
index 007d5eadb8e3..000000000000
--- a/build-scripts/rollup-plugins/worker-plugin.cjs
+++ /dev/null
@@ -1,152 +0,0 @@
-// Worker plugin
-// Each worker will include all of its dependencies
-// instead of relying on an importer.
-
-// Forked from v.1.4.1
-// https://github.com/surma/rollup-plugin-off-main-thread
-/**
- * Copyright 2018 Google Inc. All Rights Reserved.
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- * http://www.apache.org/licenses/LICENSE-2.0
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-const rollup = require("rollup");
-const path = require("path");
-const MagicString = require("magic-string");
-
-const defaultOpts = {
- // A RegExp to find `new Workers()` calls. The second capture group _must_
- // capture the provided file name without the quotes.
- workerRegexp: /new Worker\((["'])(.+?)\1(,[^)]+)?\)/g,
- plugins: ["node-resolve", "commonjs", "babel", "terser", "ignore"],
-};
-
-async function getBundledWorker(workerPath, rollupOptions) {
- const bundle = await rollup.rollup({
- ...rollupOptions,
- input: {
- worker: workerPath,
- },
- });
- const { output } = await bundle.generate({
- // Generates cleanest output, we shouldn't have any imports/exports
- // that would be incompatible with ES5.
- format: "es",
- // We should not export anything. This will fail build if we are.
- exports: "none",
- });
-
- let code;
-
- for (const chunkOrAsset of output) {
- if (chunkOrAsset.name === "worker") {
- code = chunkOrAsset.code;
- } else if (chunkOrAsset.type !== "asset") {
- throw new Error("Unexpected extra output");
- }
- }
-
- return code;
-}
-
-module.exports = function (opts = {}) {
- opts = { ...defaultOpts, ...opts };
-
- let rollupOptions;
- let refIds;
-
- return {
- name: "hass-worker",
-
- async buildStart(options) {
- refIds = {};
- rollupOptions = {
- plugins: options.plugins.filter((plugin) =>
- opts.plugins.includes(plugin.name)
- ),
- };
- },
-
- async transform(code, id) {
- // Copy the regexp as they are stateful and this hook is async.
- const workerRegexp = new RegExp(
- opts.workerRegexp.source,
- opts.workerRegexp.flags
- );
- if (!workerRegexp.test(code)) {
- return undefined;
- }
-
- const ms = new MagicString(code);
- // Reset the regexp
- workerRegexp.lastIndex = 0;
- for (;;) {
- const match = workerRegexp.exec(code);
- if (!match) {
- break;
- }
-
- const workerFile = match[2];
- let optionsObject = {};
- // Parse the optional options object
- if (match[3] && match[3].length > 0) {
- // FIXME: ooooof!
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
- optionsObject = new Function(`return ${match[3].slice(1)};`)();
- }
- delete optionsObject.type;
-
- if (!/^.*\//.test(workerFile)) {
- this.warn(
- `Paths passed to the Worker constructor must be relative or absolute, i.e. start with /, ./ or ../ (just like dynamic import!). Ignoring "${workerFile}".`
- );
- continue;
- }
-
- // Find worker file and store it as a chunk with ID prefixed for our loader
- // eslint-disable-next-line no-await-in-loop
- const resolvedWorkerFile = (await this.resolve(workerFile, id)).id;
- let chunkRefId;
- if (resolvedWorkerFile in refIds) {
- chunkRefId = refIds[resolvedWorkerFile];
- } else {
- this.addWatchFile(resolvedWorkerFile);
- // eslint-disable-next-line no-await-in-loop
- const source = await getBundledWorker(
- resolvedWorkerFile,
- rollupOptions
- );
- chunkRefId = refIds[resolvedWorkerFile] = this.emitFile({
- name: path.basename(resolvedWorkerFile),
- source,
- type: "asset",
- });
- }
-
- const workerParametersStartIndex = match.index + "new Worker(".length;
- const workerParametersEndIndex =
- match.index + match[0].length - ")".length;
-
- ms.overwrite(
- workerParametersStartIndex,
- workerParametersEndIndex,
- `import.meta.ROLLUP_FILE_URL_${chunkRefId}, ${JSON.stringify(
- optionsObject
- )}`
- );
- }
-
- return {
- code: ms.toString(),
- map: ms.generateMap({ hires: true }),
- };
- },
- };
-};
diff --git a/build-scripts/rollup.cjs b/build-scripts/rollup.cjs
deleted file mode 100644
index e3becb97aeb7..000000000000
--- a/build-scripts/rollup.cjs
+++ /dev/null
@@ -1,146 +0,0 @@
-const path = require("path");
-
-const commonjs = require("@rollup/plugin-commonjs");
-const resolve = require("@rollup/plugin-node-resolve");
-const json = require("@rollup/plugin-json");
-const { babel } = require("@rollup/plugin-babel");
-const replace = require("@rollup/plugin-replace");
-const visualizer = require("rollup-plugin-visualizer");
-const { string } = require("rollup-plugin-string");
-const { terser } = require("rollup-plugin-terser");
-const manifest = require("./rollup-plugins/manifest-plugin.cjs");
-const worker = require("./rollup-plugins/worker-plugin.cjs");
-const dontHashPlugin = require("./rollup-plugins/dont-hash-plugin.cjs");
-const ignore = require("./rollup-plugins/ignore-plugin.cjs");
-
-const bundle = require("./bundle.cjs");
-const paths = require("./paths.cjs");
-
-const extensions = [".js", ".ts"];
-
-/**
- * @param {Object} arg
- * @param { import("rollup").InputOption } arg.input
- */
-const createRollupConfig = ({
- entry,
- outputPath,
- defineOverlay,
- isProdBuild,
- latestBuild,
- isStatsBuild,
- publicPath,
- dontHash,
- isWDS,
-}) => ({
- /**
- * @type { import("rollup").InputOptions }
- */
- inputOptions: {
- input: entry,
- // Some entry points contain no JavaScript. This setting silences a warning about that.
- // https://rollupjs.org/configuration-options/#preserveentrysignatures
- preserveEntrySignatures: false,
- plugins: [
- ignore({
- files: bundle
- .emptyPackages({ latestBuild })
- // TEMP HACK: Makes Rollup build work again
- .concat(
- require.resolve(
- "@webcomponents/scoped-custom-element-registry/scoped-custom-element-registry.min"
- )
- ),
- }),
- resolve({
- extensions,
- preferBuiltins: false,
- browser: true,
- rootDir: paths.polymer_dir,
- }),
- commonjs(),
- json(),
- babel({
- ...bundle.babelOptions({ latestBuild, isProdBuild }),
- extensions,
- babelHelpers: isWDS ? "inline" : "bundled",
- }),
- string({
- // Import certain extensions as strings
- include: [path.join(paths.polymer_dir, "node_modules/**/*.css")],
- }),
- replace(bundle.definedVars({ isProdBuild, latestBuild, defineOverlay })),
- !isWDS &&
- manifest({
- publicPath,
- }),
- !isWDS && worker(),
- !isWDS && dontHashPlugin({ dontHash }),
- !isWDS && isProdBuild && terser(bundle.terserOptions({ latestBuild })),
- !isWDS &&
- isStatsBuild &&
- visualizer({
- // https://github.com/btd/rollup-plugin-visualizer#options
- open: true,
- sourcemap: true,
- }),
- ].filter(Boolean),
- },
- /**
- * @type { import("rollup").OutputOptions }
- */
- outputOptions: {
- // https://rollupjs.org/configuration-options/#output-dir
- dir: outputPath,
- // https://rollupjs.org/configuration-options/#output-format
- format: latestBuild ? "es" : "systemjs",
- // https://rollupjs.org/configuration-options/#output-externallivebindings
- externalLiveBindings: false,
- // https://rollupjs.org/configuration-options/#output-entryfilenames
- // https://rollupjs.org/configuration-options/#output-chunkfilenames
- // https://rollupjs.org/configuration-options/#output-assetfilenames
- entryFileNames:
- isProdBuild && !isStatsBuild ? "[name]-[hash].js" : "[name].js",
- chunkFileNames: isProdBuild && !isStatsBuild ? "c.[hash].js" : "[name].js",
- assetFileNames: isProdBuild && !isStatsBuild ? "a.[hash].js" : "[name].js",
- // https://rollupjs.org/configuration-options/#output-sourcemap
- sourcemap: isProdBuild ? true : "inline",
- },
-});
-
-const createAppConfig = ({ isProdBuild, latestBuild, isStatsBuild, isWDS }) =>
- createRollupConfig(
- bundle.config.app({
- isProdBuild,
- latestBuild,
- isStatsBuild,
- isWDS,
- })
- );
-
-const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
- createRollupConfig(
- bundle.config.demo({
- isProdBuild,
- latestBuild,
- isStatsBuild,
- })
- );
-
-const createCastConfig = ({ isProdBuild, latestBuild }) =>
- createRollupConfig(bundle.config.cast({ isProdBuild, latestBuild }));
-
-const createHassioConfig = ({ isProdBuild, latestBuild }) =>
- createRollupConfig(bundle.config.hassio({ isProdBuild, latestBuild }));
-
-const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
- createRollupConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
-
-module.exports = {
- createAppConfig,
- createDemoConfig,
- createCastConfig,
- createHassioConfig,
- createGalleryConfig,
- createRollupConfig,
-};
diff --git a/build-scripts/webpack.cjs b/build-scripts/webpack.cjs
index 94ca35b8f553..83f54fb58453 100644
--- a/build-scripts/webpack.cjs
+++ b/build-scripts/webpack.cjs
@@ -188,6 +188,7 @@ const createWebpackConfig = ({
"lit/directives/cache$": "lit/directives/cache.js",
"lit/directives/repeat$": "lit/directives/repeat.js",
"lit/directives/live$": "lit/directives/live.js",
+ "lit/directives/keyed$": "lit/directives/keyed.js",
"lit/polyfill-support$": "lit/polyfill-support.js",
"@lit-labs/virtualizer/layouts/grid":
"@lit-labs/virtualizer/layouts/grid.js",
diff --git a/cast/rollup.config.js b/cast/rollup.config.js
deleted file mode 100644
index f598f1a4fa09..000000000000
--- a/cast/rollup.config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import rollup from "../build-scripts/rollup.cjs";
-import env from "../build-scripts/env.cjs";
-
-const config = rollup.createCastConfig({
- isProdBuild: env.isProdBuild(),
- latestBuild: true,
- isStatsBuild: env.isStatsBuild(),
-});
-
-export default { ...config.inputOptions, output: config.outputOptions };
diff --git a/demo/rollup.config.js b/demo/rollup.config.js
deleted file mode 100644
index 90cff26a9551..000000000000
--- a/demo/rollup.config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import rollup from "../build-scripts/rollup.cjs";
-import env from "../build-scripts/env.cjs";
-
-const config = rollup.createDemoConfig({
- isProdBuild: env.isProdBuild(),
- latestBuild: true,
- isStatsBuild: env.isStatsBuild(),
-});
-
-export default { ...config.inputOptions, output: config.outputOptions };
diff --git a/gallery/rollup.config.js b/gallery/rollup.config.js
deleted file mode 100644
index 850407482284..000000000000
--- a/gallery/rollup.config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import rollup from "../build-scripts/rollup.cjs";
-import env from "../build-scripts/env.cjs";
-
-const config = rollup.createGalleryConfig({
- isProdBuild: env.isProdBuild(),
- latestBuild: true,
- isStatsBuild: env.isStatsBuild(),
-});
-
-export default { ...config.inputOptions, output: config.outputOptions };
diff --git a/gallery/src/pages/components/ha-form.ts b/gallery/src/pages/components/ha-form.ts
index 61495e4f785d..fe877ad1d8c5 100644
--- a/gallery/src/pages/components/ha-form.ts
+++ b/gallery/src/pages/components/ha-form.ts
@@ -510,6 +510,7 @@ class DemoHaForm extends LitElement {
.computeError=${(error) => translations[error] || error}
.computeLabel=${(schema) =>
translations[schema.name] || schema.name}
+ .computeHelper=${() => "Helper text"}
@value-changed=${(e) => {
this.data[idx] = e.detail.value;
this.requestUpdate();
diff --git a/hassio/rollup.config.js b/hassio/rollup.config.js
deleted file mode 100644
index 41835a7fa86f..000000000000
--- a/hassio/rollup.config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import rollup from "../build-scripts/rollup.cjs";
-import env from "../build-scripts/env.cjs";
-
-const config = rollup.createHassioConfig({
- isProdBuild: env.isProdBuild(),
- latestBuild: false,
- isStatsBuild: env.isStatsBuild(),
-});
-
-export default { ...config.inputOptions, output: config.outputOptions };
diff --git a/hassio/src/addon-view/log/hassio-addon-log-tab.ts b/hassio/src/addon-view/log/hassio-addon-log-tab.ts
index 9f52d3e83296..34ce4f462669 100644
--- a/hassio/src/addon-view/log/hassio-addon-log-tab.ts
+++ b/hassio/src/addon-view/log/hassio-addon-log-tab.ts
@@ -38,15 +38,15 @@ class HassioAddonLogDashboard extends LitElement {
@value-changed=${this._filterChanged}
.hass=${this.hass}
.filter=${this._filter}
- .label=${this.hass.localize("ui.panel.config.logs.search")}
+ .label=${this.supervisor.localize("ui.panel.config.logs.search")}
>
diff --git a/package.json b/package.json
index bc8c9ae41e38..cbc2fe6d0de3 100644
--- a/package.json
+++ b/package.json
@@ -27,22 +27,22 @@
"dependencies": {
"@babel/runtime": "7.26.0",
"@braintree/sanitize-url": "7.1.0",
- "@codemirror/autocomplete": "6.18.2",
+ "@codemirror/autocomplete": "6.18.3",
"@codemirror/commands": "6.7.1",
"@codemirror/language": "6.10.3",
- "@codemirror/legacy-modes": "6.4.1",
+ "@codemirror/legacy-modes": "6.4.2",
"@codemirror/search": "6.5.7",
"@codemirror/state": "6.4.1",
- "@codemirror/view": "6.34.1",
+ "@codemirror/view": "6.34.2",
"@egjs/hammerjs": "2.0.17",
- "@formatjs/intl-datetimeformat": "6.16.1",
- "@formatjs/intl-displaynames": "6.8.1",
- "@formatjs/intl-getcanonicallocales": "2.5.1",
- "@formatjs/intl-listformat": "7.7.1",
- "@formatjs/intl-locale": "4.2.1",
- "@formatjs/intl-numberformat": "8.14.1",
- "@formatjs/intl-pluralrules": "5.3.1",
- "@formatjs/intl-relativetimeformat": "11.4.1",
+ "@formatjs/intl-datetimeformat": "6.16.4",
+ "@formatjs/intl-displaynames": "6.8.4",
+ "@formatjs/intl-getcanonicallocales": "2.5.2",
+ "@formatjs/intl-listformat": "7.7.4",
+ "@formatjs/intl-locale": "4.2.4",
+ "@formatjs/intl-numberformat": "8.14.4",
+ "@formatjs/intl-pluralrules": "5.3.4",
+ "@formatjs/intl-relativetimeformat": "11.4.4",
"@fullcalendar/core": "6.1.15",
"@fullcalendar/daygrid": "6.1.15",
"@fullcalendar/interaction": "6.1.15",
@@ -89,8 +89,8 @@
"@polymer/polymer": "3.5.2",
"@replit/codemirror-indentation-markers": "6.5.3",
"@thomasloven/round-slider": "0.6.0",
- "@vaadin/combo-box": "24.5.1",
- "@vaadin/vaadin-themable-mixin": "24.5.1",
+ "@vaadin/combo-box": "24.5.3",
+ "@vaadin/vaadin-themable-mixin": "24.5.3",
"@vibrant/color": "3.2.1-alpha.1",
"@vibrant/core": "3.2.1-alpha.1",
"@vibrant/quantizer-mmcq": "3.2.1-alpha.1",
@@ -98,10 +98,10 @@
"@webcomponents/scoped-custom-element-registry": "0.0.9",
"@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1",
- "barcode-detector": "2.2.11",
+ "barcode-detector": "2.3.1",
"chart.js": "4.4.6",
"color-name": "2.0.0",
- "comlink": "4.4.1",
+ "comlink": "4.4.2",
"core-js": "3.39.0",
"cropperjs": "1.6.2",
"date-fns": "4.1.0",
@@ -115,13 +115,13 @@
"hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch",
"home-assistant-js-websocket": "9.4.0",
"idb-keyval": "6.2.1",
- "intl-messageformat": "10.7.3",
+ "intl-messageformat": "10.7.6",
"js-yaml": "4.1.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"lit": "2.8.0",
"luxon": "3.5.0",
- "marked": "14.1.3",
+ "marked": "15.0.0",
"memoize-one": "6.0.0",
"node-vibrant": "3.2.1-alpha.1",
"proxy-polyfill": "0.3.2",
@@ -153,7 +153,7 @@
},
"devDependencies": {
"@babel/core": "7.26.0",
- "@babel/helper-define-polyfill-provider": "0.6.2",
+ "@babel/helper-define-polyfill-provider": "0.6.3",
"@babel/plugin-proposal-decorators": "7.25.9",
"@babel/plugin-transform-runtime": "7.25.9",
"@babel/preset-env": "7.26.0",
@@ -165,13 +165,8 @@
"@octokit/plugin-retry": "7.1.2",
"@octokit/rest": "21.0.2",
"@open-wc/dev-server-hmr": "0.1.4",
- "@rollup/plugin-babel": "6.0.4",
- "@rollup/plugin-commonjs": "26.0.1",
- "@rollup/plugin-json": "6.1.0",
- "@rollup/plugin-node-resolve": "15.2.4",
- "@rollup/plugin-replace": "5.0.7",
"@types/babel__plugin-transform-runtime": "7.9.5",
- "@types/chromecast-caf-receiver": "6.0.17",
+ "@types/chromecast-caf-receiver": "6.0.18",
"@types/chromecast-caf-sender": "1.0.10",
"@types/color-name": "2.0.0",
"@types/glob": "8.1.0",
@@ -191,7 +186,6 @@
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"@web/dev-server": "0.1.38",
- "@web/dev-server-rollup": "0.4.1",
"babel-loader": "9.2.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
@@ -230,10 +224,6 @@
"open": "10.1.0",
"pinst": "3.0.0",
"prettier": "3.3.3",
- "rollup": "2.79.2",
- "rollup-plugin-string": "3.0.0",
- "rollup-plugin-terser": "7.0.2",
- "rollup-plugin-visualizer": "5.12.0",
"serve-handler": "6.1.6",
"sinon": "19.0.2",
"systemjs": "6.15.1",
@@ -247,7 +237,7 @@
"webpack-dev-server": "5.1.0",
"webpack-manifest-plugin": "5.0.0",
"webpack-stats-plugin": "1.1.3",
- "webpackbar": "6.0.1",
+ "webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
},
"_comment": "Polymer 3.2 contained a bug, fixed in https://github.com/Polymer/polymer/pull/5569, add as patch",
diff --git a/pyproject.toml b/pyproject.toml
index 86a0d5524248..eeaa6db0632c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
-version = "20241104.0"
+version = "20241106.0"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"
diff --git a/rollup.config.js b/rollup.config.js
deleted file mode 100644
index 0b59a654d213..000000000000
--- a/rollup.config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import rollup from "../build-scripts/rollup.cjs";
-import env from "../build-scripts/env.cjs";
-
-const config = rollup.createAppConfig({
- isProdBuild: env.isProdBuild(),
- latestBuild: true,
- isStatsBuild: env.isStatsBuild(),
-});
-
-export default { ...config.inputOptions, output: config.outputOptions };
diff --git a/src/auth/ha-auth-flow.ts b/src/auth/ha-auth-flow.ts
index 668beb4685a6..b860d9e426db 100644
--- a/src/auth/ha-auth-flow.ts
+++ b/src/auth/ha-auth-flow.ts
@@ -3,6 +3,7 @@ import "@material/mwc-button";
import { genClientId } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
+import { keyed } from "lit/directives/keyed";
import { customElement, property, state } from "lit/decorators";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
@@ -224,16 +225,19 @@ export class HaAuthFlow extends LitElement {
: this.localize("ui.panel.page-authorize.just_checking")}
${this._computeStepDescription(step)}
-
+ ${keyed(
+ step.step_id,
+ html`
`
+ )}
${this.clientId === genClientId() &&
!["select_mfa_module", "mfa"].includes(step.step_id)
? html`
diff --git a/src/auth/ha-auth-form-string.ts b/src/auth/ha-auth-form-string.ts
index 77de5c495ad8..4502513f7d44 100644
--- a/src/auth/ha-auth-form-string.ts
+++ b/src/auth/ha-auth-form-string.ts
@@ -54,6 +54,7 @@ export class HaAuthFormString extends HaFormString {
.autoValidate=${this.schema.required}
.name=${this.schema.name}
.autocomplete=${this.schema.autocomplete}
+ ?autofocus=${this.schema.autofocus}
.suffix=${
this.isPassword
? // reserve some space for the icon.
diff --git a/src/auth/ha-auth-textfield.ts b/src/auth/ha-auth-textfield.ts
index ca4f8e1f1904..b13e661c563a 100644
--- a/src/auth/ha-auth-textfield.ts
+++ b/src/auth/ha-auth-textfield.ts
@@ -69,6 +69,7 @@ export class HaAuthTextField extends HaTextField {
name=${ifDefined(this.name === "" ? undefined : this.name)}
inputmode=${ifDefined(this.inputMode)}
autocapitalize=${ifDefined(autocapitalizeOrUndef)}
+ ?autofocus=${this.autofocus}
@input=${this.handleInputChange}
@focus=${this.onInputFocus}
@blur=${this.onInputBlur}
@@ -246,6 +247,14 @@ export class HaAuthTextField extends HaTextField {
this.append(style);
return this;
}
+
+ public firstUpdated() {
+ super.firstUpdated();
+
+ if (this.autofocus) {
+ this.focus();
+ }
+ }
}
declare global {
diff --git a/src/common/datetime/format_duration.ts b/src/common/datetime/format_duration.ts
index c530a20d4aba..9279a8b0ce47 100644
--- a/src/common/datetime/format_duration.ts
+++ b/src/common/datetime/format_duration.ts
@@ -1,5 +1,6 @@
import type { HaDurationData } from "../../components/ha-duration-input";
import type { FrontendLocaleData } from "../../data/translation";
+import { formatListWithAnds } from "../string/format-list";
const leftPad = (num: number) => (num < 10 ? `0${num}` : num);
@@ -42,3 +43,62 @@ export const formatDuration = (
}
return null;
};
+
+export const formatDurationLong = (
+ locale: FrontendLocaleData,
+ duration: HaDurationData
+) => {
+ const d = duration.days || 0;
+ const h = duration.hours || 0;
+ const m = duration.minutes || 0;
+ const s = duration.seconds || 0;
+ const ms = duration.milliseconds || 0;
+
+ const parts: string[] = [];
+ if (d > 0) {
+ parts.push(
+ Intl.NumberFormat(locale.language, {
+ style: "unit",
+ unit: "day",
+ unitDisplay: "long",
+ }).format(d)
+ );
+ }
+ if (h > 0) {
+ parts.push(
+ Intl.NumberFormat(locale.language, {
+ style: "unit",
+ unit: "hour",
+ unitDisplay: "long",
+ }).format(h)
+ );
+ }
+ if (m > 0) {
+ parts.push(
+ Intl.NumberFormat(locale.language, {
+ style: "unit",
+ unit: "minute",
+ unitDisplay: "long",
+ }).format(m)
+ );
+ }
+ if (s > 0) {
+ parts.push(
+ Intl.NumberFormat(locale.language, {
+ style: "unit",
+ unit: "second",
+ unitDisplay: "long",
+ }).format(s)
+ );
+ }
+ if (ms > 0) {
+ parts.push(
+ Intl.NumberFormat(locale.language, {
+ style: "unit",
+ unit: "millisecond",
+ unitDisplay: "long",
+ }).format(ms)
+ );
+ }
+ return formatListWithAnds(locale, parts);
+};
diff --git a/src/common/entity/delete_entity.ts b/src/common/entity/delete_entity.ts
new file mode 100644
index 000000000000..4f104716692c
--- /dev/null
+++ b/src/common/entity/delete_entity.ts
@@ -0,0 +1,91 @@
+import type { HomeAssistant } from "../../types";
+import type { IntegrationManifest } from "../../data/integration";
+import { computeDomain } from "./compute_domain";
+import { HELPERS_CRUD } from "../../data/helpers_crud";
+import type { Helper } from "../../panels/config/helpers/const";
+import { isHelperDomain } from "../../panels/config/helpers/const";
+import { isComponentLoaded } from "../config/is_component_loaded";
+import type { EntityRegistryEntry } from "../../data/entity_registry";
+import { removeEntityRegistryEntry } from "../../data/entity_registry";
+import type { ConfigEntry } from "../../data/config_entries";
+import { deleteConfigEntry } from "../../data/config_entries";
+
+export const isDeletableEntity = (
+ hass: HomeAssistant,
+ entity_id: string,
+ manifests: IntegrationManifest[],
+ entityRegistry: EntityRegistryEntry[],
+ configEntries: ConfigEntry[],
+ fetchedHelpers: Helper[]
+): boolean => {
+ const restored = !!hass.states[entity_id]?.attributes.restored;
+ if (restored) {
+ return true;
+ }
+
+ const domain = computeDomain(entity_id);
+ const entityRegEntry = entityRegistry.find((e) => e.entity_id === entity_id);
+ if (isHelperDomain(domain)) {
+ return !!(
+ isComponentLoaded(hass, domain) &&
+ entityRegEntry &&
+ fetchedHelpers.some((e) => e.id === entityRegEntry.unique_id)
+ );
+ }
+
+ const configEntryId = entityRegEntry?.config_entry_id;
+ if (!configEntryId) {
+ return false;
+ }
+ const configEntry = configEntries.find((e) => e.entry_id === configEntryId);
+ return (
+ manifests.find((m) => m.domain === configEntry?.domain)
+ ?.integration_type === "helper"
+ );
+};
+
+export const deleteEntity = (
+ hass: HomeAssistant,
+ entity_id: string,
+ manifests: IntegrationManifest[],
+ entityRegistry: EntityRegistryEntry[],
+ configEntries: ConfigEntry[],
+ fetchedHelpers: Helper[]
+) => {
+ // This function assumes the entity_id already was validated by isDeletableEntity and does not repeat all those checks.
+ const domain = computeDomain(entity_id);
+ const entityRegEntry = entityRegistry.find((e) => e.entity_id === entity_id);
+ if (isHelperDomain(domain)) {
+ if (isComponentLoaded(hass, domain)) {
+ if (
+ entityRegEntry &&
+ fetchedHelpers.some((e) => e.id === entityRegEntry.unique_id)
+ ) {
+ HELPERS_CRUD[domain].delete(hass, entityRegEntry.unique_id);
+ return;
+ }
+ }
+ const stateObj = hass.states[entity_id];
+ if (!stateObj?.attributes.restored) {
+ return;
+ }
+ removeEntityRegistryEntry(hass, entity_id);
+ return;
+ }
+
+ const configEntryId = entityRegEntry?.config_entry_id;
+ const configEntry = configEntryId
+ ? configEntries.find((e) => e.entry_id === configEntryId)
+ : undefined;
+ const isHelperEntryType = configEntry
+ ? manifests.find((m) => m.domain === configEntry.domain)
+ ?.integration_type === "helper"
+ : false;
+
+ if (isHelperEntryType) {
+ deleteConfigEntry(hass, configEntryId!);
+ return;
+ }
+
+ removeEntityRegistryEntry(hass, entity_id);
+};
diff --git a/src/common/translations/blank_before_percent.ts b/src/common/translations/blank_before_percent.ts
index 9ef5afc8c7d3..7fe331b73ede 100644
--- a/src/common/translations/blank_before_percent.ts
+++ b/src/common/translations/blank_before_percent.ts
@@ -5,7 +5,7 @@ export const blankBeforePercent = (
localeOptions: FrontendLocaleData
): string => {
switch (localeOptions.language) {
- case "cz":
+ case "cs":
case "de":
case "fi":
case "fr":
diff --git a/src/common/translations/localize.ts b/src/common/translations/localize.ts
index 49d813c7a054..66da3c4327c9 100644
--- a/src/common/translations/localize.ts
+++ b/src/common/translations/localize.ts
@@ -16,6 +16,8 @@ export type LocalizeKeys =
| `ui.card.lawn_mower.actions.${string}`
| `ui.components.calendar.event.rrule.${string}`
| `ui.components.selectors.file.${string}`
+ | `ui.components.logbook.messages.detected_device_classes.${string}`
+ | `ui.components.logbook.messages.cleared_device_classes.${string}`
| `ui.dialogs.entity_registry.editor.${string}`
| `ui.dialogs.more_info_control.lawn_mower.${string}`
| `ui.dialogs.more_info_control.vacuum.${string}`
diff --git a/src/components/data-table/ha-data-table-labels.ts b/src/components/data-table/ha-data-table-labels.ts
index 31dc5be18535..b35656be822b 100644
--- a/src/components/data-table/ha-data-table-labels.ts
+++ b/src/components/data-table/ha-data-table-labels.ts
@@ -113,7 +113,6 @@ class HaDataTableLabels extends LitElement {
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
- outline: 1px solid var(--outline-color);
}
ha-button-menu {
border-radius: 10px;
diff --git a/src/components/ha-ansi-to-html.ts b/src/components/ha-ansi-to-html.ts
index ed0b3773e3dd..5956303ab415 100644
--- a/src/components/ha-ansi-to-html.ts
+++ b/src/components/ha-ansi-to-html.ts
@@ -12,6 +12,7 @@ import {
query,
state as litState,
} from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
interface State {
bold: boolean;
@@ -26,12 +27,15 @@ interface State {
export class HaAnsiToHtml extends LitElement {
@property() public content!: string;
+ @property({ type: Boolean, attribute: "wrap-disabled" }) public wrapDisabled =
+ false;
+
@query("pre") private _pre?: HTMLPreElement;
@litState() private _filter = "";
protected render(): TemplateResult | void {
- return html`
`;
+ return html`
`;
}
protected firstUpdated(_changedProperties: PropertyValues): void {
@@ -47,9 +51,11 @@ export class HaAnsiToHtml extends LitElement {
return css`
pre {
overflow-x: auto;
+ margin: 0;
+ }
+ pre.wrap {
white-space: pre-wrap;
overflow-wrap: break-word;
- margin: 0;
}
.bold {
font-weight: bold;
diff --git a/src/components/ha-form/ha-form-boolean.ts b/src/components/ha-form/ha-form-boolean.ts
index b56ccd2d2481..11c7033e0a4d 100644
--- a/src/components/ha-form/ha-form-boolean.ts
+++ b/src/components/ha-form/ha-form-boolean.ts
@@ -1,6 +1,6 @@
import "@material/mwc-formfield";
-import type { TemplateResult } from "lit";
-import { html, LitElement } from "lit";
+import type { CSSResultGroup, TemplateResult } from "lit";
+import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type {
@@ -19,6 +19,8 @@ export class HaFormBoolean extends LitElement implements HaFormElement {
@property() public label!: string;
+ @property() public helper?: string;
+
@property({ type: Boolean }) public disabled = false;
@query("ha-checkbox", true) private _input?: HTMLElement;
@@ -37,6 +39,12 @@ export class HaFormBoolean extends LitElement implements HaFormElement {
.disabled=${this.disabled}
@change=${this._valueChanged}
>
+
+ ${this.label}
+ ${this.helper
+ ? html`${this.helper}
`
+ : nothing}
+
`;
}
@@ -46,6 +54,28 @@ export class HaFormBoolean extends LitElement implements HaFormElement {
value: (ev.target as HaCheckbox).checked,
});
}
+
+ static get styles(): CSSResultGroup {
+ return css`
+ ha-formfield {
+ display: flex;
+ min-height: 56px;
+ align-items: center;
+ --mdc-typography-body2-font-size: 1em;
+ }
+ p {
+ margin: 0;
+ }
+ .secondary {
+ direction: var(--direction);
+ padding-top: 4px;
+ box-sizing: border-box;
+ color: var(--secondary-text-color);
+ font-size: 0.875rem;
+ font-weight: var(--mdc-typography-body2-font-weight, 400);
+ }
+ `;
+ }
}
declare global {
diff --git a/src/components/ha-form/types.ts b/src/components/ha-form/types.ts
index 437ffd17ea73..5c5ab10643a6 100644
--- a/src/components/ha-form/types.ts
+++ b/src/components/ha-form/types.ts
@@ -85,6 +85,7 @@ export interface HaFormStringSchema extends HaFormBaseSchema {
type: "string";
format?: string;
autocomplete?: string;
+ autofocus?: boolean;
}
export interface HaFormBooleanSchema extends HaFormBaseSchema {
diff --git a/src/components/ha-grid-size-picker.ts b/src/components/ha-grid-size-picker.ts
index f3b5607c21d1..49a293e33d25 100644
--- a/src/components/ha-grid-size-picker.ts
+++ b/src/components/ha-grid-size-picker.ts
@@ -2,9 +2,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../panels/lovelace/editor/card-editor/ha-grid-layout-slider";
import "./ha-icon-button";
-
import { mdiRestore } from "@mdi/js";
-import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { conditionalClamp } from "../common/number/clamp";
@@ -20,7 +18,7 @@ export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public rows = 8;
- @property({ attribute: false }) public columns = 4;
+ @property({ attribute: false }) public columns = 12;
@property({ attribute: false }) public rowMin?: number;
@@ -32,6 +30,8 @@ export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public isDefault?: boolean;
+ @property({ attribute: false }) public step: number = 1;
+
@state() public _localValue?: CardGridSize = { rows: 1, columns: 1 };
protected willUpdate(changedProperties) {
@@ -51,8 +51,9 @@ export class HaGridSizeEditor extends LitElement {
const rowMin = this.rowMin ?? 1;
const rowMax = this.rowMax ?? this.rows;
- const columnMin = this.columnMin ?? 1;
- const columnMax = this.columnMax ?? this.columns;
+ const columnMin = Math.ceil((this.columnMin ?? 1) / this.step) * this.step;
+ const columnMax =
+ Math.ceil((this.columnMax ?? this.columns) / this.step) * this.step;
const rowValue = autoHeight ? rowMin : this._localValue?.rows;
const columnValue = this._localValue?.columns;
@@ -67,9 +68,11 @@ export class HaGridSizeEditor extends LitElement {
.max=${columnMax}
.range=${this.columns}
.value=${fullWidth ? this.columns : this.value?.columns}
+ .step=${this.step}
@value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved}
.disabled=${disabledColumns}
+ tooltip-mode="always"
>
${!this.isDefault
? html`
@@ -102,34 +106,44 @@ export class HaGridSizeEditor extends LitElement {
`
: nothing}
-
-
- ${Array(this.rows * this.columns)
+
+
+ ${Array(this.rows)
.fill(0)
.map((_, index) => {
- const row = Math.floor(index / this.columns) + 1;
- const column = (index % this.columns) + 1;
+ const row = index + 1;
return html`
-
+
+ ${Array(this.columns)
+ .fill(0)
+ .map((__, columnIndex) => {
+ const column = columnIndex + 1;
+ if (
+ column % this.step !== 0 ||
+ (this.columns > 24 && column % 3 !== 0)
+ ) {
+ return nothing;
+ }
+ return html`
+ |
+ `;
+ })}
+
`;
})}
-
-
+
+
`;
@@ -223,42 +237,40 @@ export class HaGridSizeEditor extends LitElement {
}
.reset {
grid-area: reset;
+ --mdc-icon-button-size: 36px;
}
.preview {
position: relative;
grid-area: preview;
}
- .preview > div {
- position: relative;
- display: grid;
- grid-template-columns: repeat(var(--total-columns), 1fr);
- grid-template-rows: repeat(var(--total-rows), 25px);
- gap: 4px;
+ .preview table,
+ .preview tr,
+ .preview td {
+ border: 2px dotted var(--divider-color);
+ border-collapse: collapse;
+ }
+ .preview table {
+ width: 100%;
}
- .preview .cell {
- background-color: var(--disabled-color);
- grid-column: span 1;
- grid-row: span 1;
- border-radius: 4px;
- opacity: 0.2;
+ .preview tr {
+ height: 30px;
+ }
+ .preview td {
cursor: pointer;
}
- .preview .selected {
+ .preview-card {
position: absolute;
- pointer-events: none;
top: 0;
left: 0;
- height: 100%;
- width: 100%;
- }
- .selected .cell {
background-color: var(--primary-color);
- grid-column: 1 / span min(var(--columns, 0), var(--total-columns));
- grid-row: 1 / span min(var(--rows, 0), var(--total-rows));
- opacity: 0.5;
- }
- .preview.full-width .selected .cell {
- grid-column: 1 / -1;
+ opacity: 0.3;
+ border-radius: 8px;
+ height: calc(var(--rows, 1) * 30px);
+ width: calc(var(--columns, 1) * 100% / var(--total-columns, 12));
+ pointer-events: none;
+ transition:
+ width ease-in-out 180ms,
+ height ease-in-out 180ms;
}
`,
];
diff --git a/src/components/ha-label.ts b/src/components/ha-label.ts
index 668f28017e51..1b15d914ed17 100644
--- a/src/components/ha-label.ts
+++ b/src/components/ha-label.ts
@@ -26,6 +26,7 @@ class HaLabel extends LitElement {
0.15
);
--ha-label-background-opacity: 1;
+ border: 1px solid var(--outline-color);
position: relative;
box-sizing: border-box;
display: inline-flex;
diff --git a/src/components/ha-md-dialog.ts b/src/components/ha-md-dialog.ts
index dde855e016d9..7ee73d7866d5 100644
--- a/src/components/ha-md-dialog.ts
+++ b/src/components/ha-md-dialog.ts
@@ -164,8 +164,8 @@ export class HaMdDialog extends MdDialog {
min-width: 320px;
}
- :host(:not([type="alert"])) {
- @media all and (max-width: 450px), all and (max-height: 500px) {
+ @media all and (max-width: 450px), all and (max-height: 500px) {
+ :host(:not([type="alert"])) {
min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left)
);
@@ -178,7 +178,7 @@ export class HaMdDialog extends MdDialog {
}
}
- :host ::slotted(ha-dialog-header) {
+ ::slotted(ha-dialog-header[slot="headline"]) {
display: contents;
}
@@ -190,7 +190,7 @@ export class HaMdDialog extends MdDialog {
padding: var(--dialog-content-padding, 24px);
}
.scrim {
- z-index: 10; // overlay navigation
+ z-index: 10; /* overlay navigation */
}
`,
];
diff --git a/src/components/ha-password-field.ts b/src/components/ha-password-field.ts
index f149264fdb8a..15fd9666dd34 100644
--- a/src/components/ha-password-field.ts
+++ b/src/components/ha-password-field.ts
@@ -117,8 +117,8 @@ export class HaPasswordField extends LitElement {
.autocapitalize=${this.autocapitalize}
.type=${this._unmaskedPassword ? "text" : "password"}
.suffix=${html`
`}
- @input=${this._handleInputChange}
- @change=${this._reDispatchEvent}
+ @input=${this._handleInputEvent}
+ @change=${this._handleChangeEvent}
>
-
+
`
: html`
No Logbook entries found for this step.
diff --git a/src/components/trace/ha-trace-path-details.ts b/src/components/trace/ha-trace-path-details.ts
index 2a38ca6504c8..75b4946f71ee 100644
--- a/src/components/trace/ha-trace-path-details.ts
+++ b/src/components/trace/ha-trace-path-details.ts
@@ -18,6 +18,7 @@ import "../../panels/logbook/ha-logbook-renderer";
import { traceTabStyles } from "./trace-tab-styles";
import type { HomeAssistant } from "../../types";
import type { NodeInfo } from "./hat-script-graph";
+import { describeCondition } from "../../data/automation_i18n";
const TRACE_PATH_TABS = [
"step_config",
@@ -121,6 +122,19 @@ export class HaTracePathDetails extends LitElement {
const data: ActionTraceStep[] = paths[curPath];
+ // Extract details from this.selected.config child properties used to add 'alias' (to headline), describeCondition and 'entity_id' (to result)
+ const nestPath = curPath
+ .substring(this.selected.path.length + 1)
+ .split("/");
+ let currentDetail = this.selected.config;
+ for (let i = 0; i < nestPath.length; i++) {
+ if (
+ !["undefined", "string"].includes(typeof currentDetail[nestPath[i]])
+ ) {
+ currentDetail = currentDetail[nestPath[i]];
+ }
+ }
+
parts.push(
data.map((trace, idx) => {
const { path, timestamp, result, error, changed_variables, ...rest } =
@@ -134,7 +148,9 @@ export class HaTracePathDetails extends LitElement {
return html`
${curPath === this.selected.path
- ? ""
+ ? currentDetail.alias
+ ? html`
${currentDetail.alias}
`
+ : nothing
: html`
${curPath.substring(this.selected.path.length + 1)}
`}
@@ -146,6 +162,15 @@ export class HaTracePathDetails extends LitElement {
{ number: idx + 1 }
)}
`}
+ ${curPath
+ .substring(this.selected.path.length + 1)
+ .includes("condition")
+ ? html`[${describeCondition(
+ currentDetail,
+ this.hass,
+ currentDetail.alias
+ )}]
`
+ : nothing}
${this.hass!.localize(
"ui.panel.config.automation.trace.path.executed",
{
@@ -176,6 +201,12 @@ export class HaTracePathDetails extends LitElement {
${Object.keys(rest).length === 0
? nothing
: html`
${dump(rest)}
`}
+ ${currentDetail.entity_id &&
+ curPath
+ .substring(this.selected.path.length + 1)
+ .includes("entity_id")
+ ? html`
entity: ${currentDetail.entity_id}
`
+ : nothing}
`;
})
);
@@ -291,7 +322,10 @@ export class HaTracePathDetails extends LitElement {
.entries=${entries}
.narrow=${this.narrow}
>
-
+
`
: html`
${this.hass!.localize(
diff --git a/src/components/trace/ha-trace-timeline.ts b/src/components/trace/ha-trace-timeline.ts
index 7c62b3fbb033..07e7fc360026 100644
--- a/src/components/trace/ha-trace-timeline.ts
+++ b/src/components/trace/ha-trace-timeline.ts
@@ -28,7 +28,10 @@ export class HaTraceTimeline extends LitElement {
allowPick
>
-
+
`;
}
diff --git a/src/components/trace/hat-logbook-note.ts b/src/components/trace/hat-logbook-note.ts
index 1d790a21afa7..bda976fe28af 100644
--- a/src/components/trace/hat-logbook-note.ts
+++ b/src/components/trace/hat-logbook-note.ts
@@ -1,14 +1,22 @@
-import { css, html, LitElement } from "lit";
+import { css, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
+import type { HomeAssistant } from "../../types";
@customElement("hat-logbook-note")
class HatLogbookNote extends LitElement {
- @property() public domain = "automation";
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property() public domain: "automation" | "script" = "automation";
render() {
- return html`
- Not all shown logbook entries might be related to this ${this.domain}.
- `;
+ if (this.domain === "script") {
+ return this.hass.localize(
+ "ui.panel.config.automation.trace.messages.not_all_entries_are_related_script_note"
+ );
+ }
+ return this.hass.localize(
+ "ui.panel.config.automation.trace.messages.not_all_entries_are_related_automation_note"
+ );
}
static styles = css`
diff --git a/src/data/auth.ts b/src/data/auth.ts
index 29c647fe158c..45fd8db37d40 100644
--- a/src/data/auth.ts
+++ b/src/data/auth.ts
@@ -30,11 +30,11 @@ export const autocompleteLoginFields = (schema: HaFormSchema[]) =>
if (field.type !== "string") return field;
switch (field.name) {
case "username":
- return { ...field, autocomplete: "username" };
+ return { ...field, autocomplete: "username", autofocus: true };
case "password":
return { ...field, autocomplete: "current-password" };
case "code":
- return { ...field, autocomplete: "one-time-code" };
+ return { ...field, autocomplete: "one-time-code", autofocus: true };
default:
return field;
}
diff --git a/src/data/automation_i18n.ts b/src/data/automation_i18n.ts
index 5f8b9eb95c0c..2651f71246e9 100644
--- a/src/data/automation_i18n.ts
+++ b/src/data/automation_i18n.ts
@@ -1,6 +1,9 @@
import type { HassConfig } from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
-import { formatDuration } from "../common/datetime/format_duration";
+import {
+ formatDuration,
+ formatDurationLong,
+} from "../common/datetime/format_duration";
import {
formatTime,
formatTimeWithSeconds,
@@ -720,6 +723,38 @@ const tryDescribeTrigger = (
}`;
}
+ // Calendar Trigger
+ if (trigger.trigger === "calendar") {
+ const calendarEntity = hass.states[trigger.entity_id]
+ ? computeStateName(hass.states[trigger.entity_id])
+ : trigger.entity_id;
+
+ let offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
+ let offset: string | string[] = trigger.offset.startsWith("-")
+ ? trigger.offset.substring(1).split(":")
+ : trigger.offset.split(":");
+ const duration = {
+ hours: offset.length > 0 ? +offset[0] : 0,
+ minutes: offset.length > 1 ? +offset[1] : 0,
+ seconds: offset.length > 2 ? +offset[2] : 0,
+ };
+ offset = formatDurationLong(hass.locale, duration);
+ if (offset === "") {
+ offsetChoice = "other";
+ }
+
+ return hass.localize(
+ `${triggerTranslationBaseKey}.calendar.description.full`,
+ {
+ eventChoice: trigger.event,
+ offsetChoice: offsetChoice,
+ offset: offset,
+ hasCalendar: trigger.entity_id ? "true" : "false",
+ calendar: calendarEntity,
+ }
+ );
+ }
+
return (
hass.localize(
`ui.panel.config.automation.editor.triggers.type.${trigger.trigger}.label`
diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts
index 1af01d3575cd..05659e276628 100644
--- a/src/data/entity_registry.ts
+++ b/src/data/entity_registry.ts
@@ -10,8 +10,6 @@ import type { LightColor } from "./light";
import { computeDomain } from "../common/entity/compute_domain";
import type { RegistryEntry } from "./registry";
-export { subscribeEntityRegistryDisplay } from "./ws-entity_registry_display";
-
type EntityCategory = "config" | "diagnostic";
export interface EntityRegistryDisplayEntry {
diff --git a/src/data/hassio/supervisor.ts b/src/data/hassio/supervisor.ts
index a0966d1baeaa..b14ffd8e56c0 100644
--- a/src/data/hassio/supervisor.ts
+++ b/src/data/hassio/supervisor.ts
@@ -185,6 +185,15 @@ export const fetchHassioInfo = async (
export const fetchHassioBoots = async (hass: HomeAssistant) =>
hass.callApi
>("GET", `hassio/host/logs/boots`);
+export const fetchHassioLogsLegacy = async (
+ hass: HomeAssistant,
+ provider: string
+) =>
+ hass.callApi(
+ "GET",
+ `hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs`
+ );
+
export const fetchHassioLogs = async (
hass: HomeAssistant,
provider: string,
diff --git a/src/data/logbook.ts b/src/data/logbook.ts
index 712680ae9073..f41a994fd3df 100644
--- a/src/data/logbook.ts
+++ b/src/data/logbook.ts
@@ -63,8 +63,8 @@ const triggerPhrases: Record = {
triggered_by_numeric_state_of: "numeric state of", // number state trigger
triggered_by_state_of: "state of", // state trigger
triggered_by_event: "event", // event trigger
- triggered_by_time: "time", // time trigger
triggered_by_time_pattern: "time pattern", // time trigger
+ triggered_by_time: "time", // time trigger
triggered_by_homeassistant_stopping: "Home Assistant stopping", // stop event
triggered_by_homeassistant_starting: "Home Assistant starting", // start event
};
@@ -218,114 +218,32 @@ export const localizeStateMessage = (
const isOff = state === BINARY_STATE_OFF;
const device_class = stateObj.attributes.device_class;
- switch (device_class) {
- case "battery":
- if (isOn) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_low`);
- }
- if (isOff) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_normal`);
- }
- break;
-
- case "connectivity":
- if (isOn) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_connected`);
- }
- if (isOff) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_disconnected`);
- }
- break;
-
- case "door":
- case "garage_door":
- case "opening":
- case "window":
- if (isOn) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_opened`);
- }
- if (isOff) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_closed`);
- }
- break;
-
- case "lock":
- if (isOn) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_unlocked`);
- }
- if (isOff) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_locked`);
- }
- break;
-
- case "plug":
- if (isOn) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_plugged_in`);
- }
- if (isOff) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_unplugged`);
- }
- break;
-
- case "presence":
- if (isOn) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_at_home`);
- }
- if (isOff) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_away`);
- }
- break;
-
- case "safety":
- if (isOn) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_unsafe`);
- }
- if (isOff) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.was_safe`);
- }
- break;
-
- case "cold":
- case "gas":
- case "heat":
- case "moisture":
- case "motion":
- case "occupancy":
- case "power":
- case "problem":
- case "smoke":
- case "sound":
- case "vibration":
- if (isOn) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.detected_device_class`, {
+ if (device_class && (isOn || isOff)) {
+ return (
+ localize(
+ `${LOGBOOK_LOCALIZE_PATH}.${isOn ? "detected_device_classes" : "cleared_device_classes"}.${device_class}`,
+ {
device_class: autoCaseNoun(
localize(
`component.binary_sensor.entity_component.${device_class}.name`
- ),
+ ) || device_class,
hass.language
),
- });
- }
- if (isOff) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.cleared_device_class`, {
+ }
+ ) ||
+ // If there's no key for a specific device class, fallback to generic string
+ localize(
+ `${LOGBOOK_LOCALIZE_PATH}.${isOn ? "detected_device_class" : "cleared_device_class"}`,
+ {
device_class: autoCaseNoun(
localize(
`component.binary_sensor.entity_component.${device_class}.name`
- ),
+ ) || device_class,
hass.language
),
- });
- }
- break;
-
- case "tamper":
- if (isOn) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.detected_tampering`);
- }
- if (isOff) {
- return localize(`${LOGBOOK_LOCALIZE_PATH}.cleared_tampering`);
- }
- break;
+ }
+ )
+ );
}
break;
diff --git a/src/data/selector.ts b/src/data/selector.ts
index c72dacc4f187..5623ecc268cb 100644
--- a/src/data/selector.ts
+++ b/src/data/selector.ts
@@ -1,4 +1,7 @@
-import type { HassEntity } from "home-assistant-js-websocket";
+import type {
+ HassEntity,
+ HassServiceTarget,
+} from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { supportsFeature } from "../common/entity/supports-feature";
@@ -871,3 +874,65 @@ export const computeCreateDomains = (
return [...new Set(createDomains)];
};
+
+export const resolveEntityIDs = (
+ hass: HomeAssistant,
+ targetPickerValue: HassServiceTarget,
+ entities: HomeAssistant["entities"],
+ devices: HomeAssistant["devices"],
+ areas: HomeAssistant["areas"]
+): string[] => {
+ if (!targetPickerValue) {
+ return [];
+ }
+
+ const targetSelector = { target: {} };
+ const targetEntities = new Set(ensureArray(targetPickerValue.entity_id));
+ const targetDevices = new Set(ensureArray(targetPickerValue.device_id));
+ const targetAreas = new Set(ensureArray(targetPickerValue.area_id));
+ const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
+ const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
+
+ targetLabels.forEach((labelId) => {
+ const expanded = expandLabelTarget(
+ hass,
+ labelId,
+ areas,
+ devices,
+ entities,
+ targetSelector
+ );
+ expanded.devices.forEach((id) => targetDevices.add(id));
+ expanded.entities.forEach((id) => targetEntities.add(id));
+ expanded.areas.forEach((id) => targetAreas.add(id));
+ });
+
+ targetFloors.forEach((floorId) => {
+ const expanded = expandFloorTarget(hass, floorId, areas, targetSelector);
+ expanded.areas.forEach((id) => targetAreas.add(id));
+ });
+
+ targetAreas.forEach((areaId) => {
+ const expanded = expandAreaTarget(
+ hass,
+ areaId,
+ devices,
+ entities,
+ targetSelector
+ );
+ expanded.devices.forEach((id) => targetDevices.add(id));
+ expanded.entities.forEach((id) => targetEntities.add(id));
+ });
+
+ targetDevices.forEach((deviceId) => {
+ const expanded = expandDeviceTarget(
+ hass,
+ deviceId,
+ entities,
+ targetSelector
+ );
+ expanded.entities.forEach((id) => targetEntities.add(id));
+ });
+
+ return Array.from(targetEntities);
+};
diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts
index af7fe06ae324..dc4b835d3815 100644
--- a/src/data/zwave_js.ts
+++ b/src/data/zwave_js.ts
@@ -209,6 +209,17 @@ export interface ZWaveJSNodeStatus {
has_firmware_update_cc: boolean;
}
+export type ZWaveJSNodeCapabilities = {
+ [endpoint: number]: ZWaveJSEndpointCapability[];
+};
+
+export interface ZWaveJSEndpointCapability {
+ id: number;
+ name: string;
+ version: number;
+ is_secure: boolean;
+}
+
export interface ZwaveJSNodeMetadata {
node_id: number;
exclusion: string;
@@ -264,6 +275,15 @@ export interface ZWaveJSSetConfigParamData {
value: string | number;
}
+export interface ZWaveJSSetRawConfigParamData {
+ type: string;
+ device_id: string;
+ property: number;
+ value: number;
+ value_size: number;
+ value_format: number;
+}
+
export interface ZWaveJSSetConfigParamResult {
value_id?: string;
status?: string;
@@ -404,6 +424,25 @@ export interface RequestedGrant {
clientSideAuth: boolean;
}
+export const invokeZWaveCCApi = (
+ hass: HomeAssistant,
+ device_id: string,
+ command_class: number,
+ endpoint: number | undefined,
+ method_name: string,
+ parameters: any[],
+ wait_for_result?: boolean
+): Promise =>
+ hass.callWS({
+ type: "zwave_js/invoke_cc_api",
+ device_id,
+ command_class,
+ endpoint,
+ method_name,
+ parameters,
+ wait_for_result,
+ });
+
export const fetchZwaveNetworkStatus = (
hass: HomeAssistant,
device_or_entry_id: {
@@ -579,6 +618,15 @@ export const fetchZwaveNodeStatus = (
device_id,
});
+export const fetchZwaveNodeCapabilities = (
+ hass: HomeAssistant,
+ device_id: string
+): Promise =>
+ hass.callWS({
+ type: "zwave_js/node_capabilities",
+ device_id,
+ });
+
export const subscribeZwaveNodeStatus = (
hass: HomeAssistant,
device_id: string,
@@ -638,6 +686,36 @@ export const setZwaveNodeConfigParameter = (
return hass.callWS(data);
};
+export const setZwaveNodeRawConfigParameter = (
+ hass: HomeAssistant,
+ device_id: string,
+ property: number,
+ value: number,
+ value_size: number,
+ value_format: number
+): Promise => {
+ const data: ZWaveJSSetRawConfigParamData = {
+ type: "zwave_js/set_raw_config_parameter",
+ device_id,
+ property,
+ value,
+ value_size,
+ value_format,
+ };
+ return hass.callWS(data);
+};
+
+export const getZwaveNodeRawConfigParameter = (
+ hass: HomeAssistant,
+ device_id: string,
+ property: number
+): Promise =>
+ hass.callWS({
+ type: "zwave_js/get_raw_config_parameter",
+ device_id,
+ property,
+ });
+
export const reinterviewZwaveNode = (
hass: HomeAssistant,
device_id: string,
diff --git a/src/dialogs/more-info/controls/more-info-camera.ts b/src/dialogs/more-info/controls/more-info-camera.ts
index d532b5861c38..7b339db519c8 100644
--- a/src/dialogs/more-info/controls/more-info-camera.ts
+++ b/src/dialogs/more-info/controls/more-info-camera.ts
@@ -4,14 +4,20 @@ import { property, state } from "lit/decorators";
import "../../../components/ha-camera-stream";
import type { CameraEntity } from "../../../data/camera";
import type { HomeAssistant } from "../../../types";
+import "../../../components/buttons/ha-progress-button";
+import { UNAVAILABLE } from "../../../data/entity";
+import { fileDownload } from "../../../util/file_download";
+import { showToast } from "../../../util/toast";
class MoreInfoCamera extends LitElement {
- @property({ attribute: false }) public hass?: HomeAssistant;
+ @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: CameraEntity;
@state() private _attached = false;
+ @state() private _waiting = false;
+
public connectedCallback() {
super.connectedCallback();
this._attached = true;
@@ -23,7 +29,7 @@ class MoreInfoCamera extends LitElement {
}
protected render() {
- if (!this._attached || !this.hass || !this.stateObj) {
+ if (!this._attached || !this.stateObj) {
return nothing;
}
@@ -34,14 +40,70 @@ class MoreInfoCamera extends LitElement {
allow-exoplayer
controls
>
+
+
+
+ ${this.hass.localize(
+ "ui.dialogs.more_info_control.camera.download_snapshot"
+ )}
+
+
`;
}
+ private async _downloadSnapshot(ev: CustomEvent) {
+ const button = ev.currentTarget as any;
+ this._waiting = true;
+
+ try {
+ const result: Response | undefined = await this.hass.callApiRaw(
+ "GET",
+ `camera_proxy/${this.stateObj!.entity_id}`
+ );
+
+ if (!result) {
+ throw new Error("No response from API");
+ }
+
+ const blob = await result.blob();
+ const url = window.URL.createObjectURL(blob);
+ fileDownload(url);
+ } catch (err) {
+ this._waiting = false;
+ button.actionError();
+ showToast(this, {
+ message: this.hass.localize(
+ "ui.dialogs.more_info_control.camera.failed_to_download"
+ ),
+ });
+ return;
+ }
+
+ this._waiting = false;
+ button.actionSuccess();
+ }
+
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
}
+
+ .actions {
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ box-sizing: border-box;
+ padding: 12px;
+ z-index: 1;
+ gap: 8px;
+ }
`;
}
}
diff --git a/src/dialogs/more-info/controls/more-info-update.ts b/src/dialogs/more-info/controls/more-info-update.ts
index 7014a505e99f..5fe6d6d792c6 100644
--- a/src/dialogs/more-info/controls/more-info-update.ts
+++ b/src/dialogs/more-info/controls/more-info-update.ts
@@ -52,59 +52,61 @@ class MoreInfoUpdate extends LitElement {
return html`
- ${this.stateObj.attributes.in_progress
- ? supportsFeature(this.stateObj, UpdateEntityFeature.PROGRESS) &&
- this.stateObj.attributes.update_percentage !== null
- ? html`
`
- : html`
`
- : nothing}
-
${this.stateObj.attributes.title}
- ${this._error
- ? html`
${this._error}`
- : nothing}
-
-
- ${this.hass.formatEntityAttributeName(
- this.stateObj,
- "installed_version"
- )}
-
-
- ${this.stateObj.attributes.installed_version ??
- this.hass.localize("state.default.unavailable")}
-
-
-
-
- ${this.hass.formatEntityAttributeName(
- this.stateObj,
- "latest_version"
- )}
+
+ ${this.stateObj.attributes.in_progress
+ ? supportsFeature(this.stateObj, UpdateEntityFeature.PROGRESS) &&
+ this.stateObj.attributes.update_percentage !== null
+ ? html`
`
+ : html`
`
+ : nothing}
+
${this.stateObj.attributes.title}
+ ${this._error
+ ? html`
${this._error}`
+ : nothing}
+
+
+ ${this.hass.formatEntityAttributeName(
+ this.stateObj,
+ "installed_version"
+ )}
+
+
+ ${this.stateObj.attributes.installed_version ??
+ this.hass.localize("state.default.unavailable")}
+
-
- ${this.stateObj.attributes.latest_version ??
- this.hass.localize("state.default.unavailable")}
+
+
+ ${this.hass.formatEntityAttributeName(
+ this.stateObj,
+ "latest_version"
+ )}
+
+
+ ${this.stateObj.attributes.latest_version ??
+ this.hass.localize("state.default.unavailable")}
+
-
- ${this.stateObj.attributes.release_url
- ? html`
`
- : nothing}
+ ${this.stateObj.attributes.release_url
+ ? html`
`
+ : nothing}
+
${supportsFeature(this.stateObj!, UpdateEntityFeature.RELEASE_NOTES) &&
!this._error
? this._releaseNotes === undefined
@@ -293,6 +295,11 @@ class MoreInfoUpdate extends LitElement {
ha-expansion-panel {
margin: 16px 0;
}
+
+ .summary {
+ margin-bottom: 16px;
+ }
+
.row {
margin: 0;
display: flex;
@@ -308,7 +315,9 @@ class MoreInfoUpdate extends LitElement {
);
position: sticky;
bottom: 0;
- margin: 0 -24px -24px -24px;
+ margin: 0 -24px 0 -24px;
+ margin-bottom: calc(-1 * max(env(safe-area-inset-bottom), 24px));
+ padding-bottom: env(safe-area-inset-bottom);
box-sizing: border-box;
display: flex;
flex-direction: column;
diff --git a/src/external_app/external_messaging.ts b/src/external_app/external_messaging.ts
index a870b72be2ff..292746bdee17 100644
--- a/src/external_app/external_messaging.ts
+++ b/src/external_app/external_messaging.ts
@@ -264,6 +264,7 @@ export interface ExternalConfig {
hasAssist: boolean;
hasBarCodeScanner: number;
canSetupImprov: boolean;
+ downloadFileSupported: boolean;
}
export class ExternalMessaging {
diff --git a/src/html/_script_loader.html.template b/src/html/_script_loader.html.template
index 5ea3f23ac476..f56c5c01b182 100644
--- a/src/html/_script_loader.html.template
+++ b/src/html/_script_loader.html.template
@@ -9,16 +9,8 @@
diff --git a/src/panels/calendar/dialog-calendar-event-editor.ts b/src/panels/calendar/dialog-calendar-event-editor.ts
index 437db6e2b67c..eeb6560a8315 100644
--- a/src/panels/calendar/dialog-calendar-event-editor.ts
+++ b/src/panels/calendar/dialog-calendar-event-editor.ts
@@ -173,7 +173,7 @@ class DialogCalendarEventEditor extends LitElement {
.label=${this.hass.localize("ui.components.calendar.event.summary")}
.value=${this._summary}
required
- @change=${this._handleSummaryChanged}
+ @input=${this._handleSummaryChanged}
.validationMessage=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus
>
diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts
index 99e497a70b6f..c5d298e44db8 100644
--- a/src/panels/config/automation/add-automation-element-dialog.ts
+++ b/src/panels/config/automation/add-automation-element-dialog.ts
@@ -370,7 +370,7 @@ class DialogAddAutomationElement extends LitElement implements HassDialog {
}`,
description:
this.hass.localize(
- `component.${domain}.services.${service}.description`
+ `component.${dmn}.services.${service}.description`
) || services[dmn][service]?.description,
});
}
diff --git a/src/panels/config/automation/ha-automation-editor.ts b/src/panels/config/automation/ha-automation-editor.ts
index 37c9f8b77b1a..6be64adc0417 100644
--- a/src/panels/config/automation/ha-automation-editor.ts
+++ b/src/panels/config/automation/ha-automation-editor.ts
@@ -103,6 +103,8 @@ export class HaAutomationEditor extends KeyboardShortcutMixin(LitElement) {
@state() private _errors?: string;
+ @state() private _yamlErrors?: string;
+
@state() private _entityId?: string;
@state() private _mode: "gui" | "yaml" = "gui";
@@ -629,15 +631,17 @@ export class HaAutomationEditor extends KeyboardShortcutMixin(LitElement) {
private _yamlChanged(ev: CustomEvent) {
ev.stopPropagation();
+ this._dirty = true;
if (!ev.detail.isValid) {
+ this._yamlErrors = ev.detail.errorMsg;
return;
}
+ this._yamlErrors = undefined;
this._config = {
id: this._config?.id,
...normalizeAutomationConfig(ev.detail.value),
};
this._errors = undefined;
- this._dirty = true;
}
private async confirmUnsavedChanged(): Promise
{
@@ -752,7 +756,21 @@ export class HaAutomationEditor extends KeyboardShortcutMixin(LitElement) {
}
}
- private _switchUiMode() {
+ private async _switchUiMode() {
+ if (this._yamlErrors) {
+ const result = await showConfirmationDialog(this, {
+ text: html`${this.hass.localize(
+ "ui.panel.config.automation.editor.switch_ui_yaml_error"
+ )}
${this._yamlErrors}`,
+ confirmText: this.hass!.localize("ui.common.continue"),
+ destructive: true,
+ dismissText: this.hass!.localize("ui.common.cancel"),
+ });
+ if (!result) {
+ return;
+ }
+ }
+ this._yamlErrors = undefined;
this._mode = "gui";
}
@@ -792,6 +810,13 @@ export class HaAutomationEditor extends KeyboardShortcutMixin(LitElement) {
}
private async _saveAutomation(): Promise {
+ if (this._yamlErrors) {
+ showToast(this, {
+ message: this._yamlErrors,
+ });
+ return;
+ }
+
const id = this.automationId || String(Date.now());
if (!this.automationId) {
const saved = await this._promptAutomationAlias();
diff --git a/src/panels/config/core/updates/dialog-join-beta.ts b/src/panels/config/core/updates/dialog-join-beta.ts
index 5a96530f36ca..7b4d7381c56c 100644
--- a/src/panels/config/core/updates/dialog-join-beta.ts
+++ b/src/panels/config/core/updates/dialog-join-beta.ts
@@ -48,7 +48,7 @@ export class DialogJoinBeta
${this.hass.localize("ui.dialogs.join_beta_channel.backup")}
- ${this.hass.localize("ui.dialogs.join_beta_channel.warning")}
+ ${this.hass.localize("ui.dialogs.join_beta_channel.warning")}.
${this.hass.localize("ui.dialogs.join_beta_channel.release_items")}
diff --git a/src/panels/config/devices/device-detail/ha-device-entities-card.ts b/src/panels/config/devices/device-detail/ha-device-entities-card.ts
index 3d869428ca3b..f54db76e0d75 100644
--- a/src/panels/config/devices/device-detail/ha-device-entities-card.ts
+++ b/src/panels/config/devices/device-detail/ha-device-entities-card.ts
@@ -1,7 +1,8 @@
import "@material/mwc-list/mwc-list";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
-import { css, html, LitElement } from "lit";
+import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
import { until } from "lit/directives/until";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { stripPrefixFromEntityName } from "../../../../common/entity/strip_prefix_from_entity_name";
@@ -86,36 +87,42 @@ export class HaDeviceEntitiesCard extends LitElement {
return html`
-
-
- ${shownEntities.map((entry) =>
- this.hass.states[entry.entity_id]
- ? this._renderEntity(entry)
- : this._renderEntry(entry)
- )}
-
-
- ${hiddenEntities.length
- ? !this.showHidden
- ? html`
-
- `
- : html`
+ ${shownEntities.length
+ ? html`
+
- ${hiddenEntities.map((entry) => this._renderEntry(entry))}
-
-
- `
- : ""}
+
+
+ `
+ : nothing}
+ ${hiddenEntities.length
+ ? html`
+ ${!this.showHidden
+ ? html`
+
+ `
+ : html`
+
+ ${hiddenEntities.map((entry) => this._renderEntry(entry))}
+
+
+ `}
+
`
+ : nothing}
${this.hass.localize(
@@ -257,8 +264,8 @@ export class HaDeviceEntitiesCard extends LitElement {
.disabled-entry {
color: var(--secondary-text-color);
}
- #entities {
- margin-top: -24px; /* match the spacing between card title and content of the device info card above it */
+ .move-up {
+ margin-top: -24px;
}
#entities > * {
margin: 8px 16px 8px 8px;
diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts
index 06d8fe55781a..d7b61fa84b36 100644
--- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts
+++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts
@@ -5,6 +5,7 @@ import {
mdiHospitalBox,
mdiInformation,
mdiUpload,
+ mdiWrench,
} from "@mdi/js";
import { getConfigEntries } from "../../../../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../../../../data/device_registry";
@@ -98,6 +99,13 @@ export const getZwaveDeviceActions = async (
showZWaveJSNodeStatisticsDialog(el, {
device,
}),
+ },
+ {
+ label: hass.localize(
+ "ui.panel.config.zwave_js.device_info.installer_settings"
+ ),
+ icon: mdiWrench,
+ href: `/config/zwave_js/node_installer/${device.id}?config_entry=${entryId}`,
}
);
}
diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts
index 3e175b0558d7..55fb4d4312d3 100644
--- a/src/panels/config/entities/ha-config-entities.ts
+++ b/src/panels/config/entities/ha-config-entities.ts
@@ -27,6 +27,13 @@ import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
+import {
+ isDeletableEntity,
+ deleteEntity,
+} from "../../../common/entity/delete_entity";
+import type { Helper } from "../helpers/const";
+import { isHelperDomain } from "../helpers/const";
+import { HELPERS_CRUD } from "../../../data/helpers_crud";
import { computeStateName } from "../../../common/entity/compute_state_name";
import {
PROTOCOL_INTEGRATIONS,
@@ -73,12 +80,15 @@ import type {
} from "../../../data/entity_registry";
import {
computeEntityRegistryName,
- removeEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../../data/entity_registry";
+import type { IntegrationManifest } from "../../../data/integration";
+import {
+ fetchIntegrationManifests,
+ domainToName,
+} from "../../../data/integration";
import type { EntitySources } from "../../../data/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity_sources";
-import { domainToName } from "../../../data/integration";
import type { LabelRegistryEntry } from "../../../data/label_registry";
import {
createLabelRegistryEntry,
@@ -136,6 +146,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
@state() private _entries?: ConfigEntry[];
+ @state() private _manifests?: IntegrationManifest[];
+
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entities!: EntityRegistryEntry[];
@@ -1280,11 +1292,46 @@ ${rejected
});
}
- private _removeSelected() {
- const removeableEntities = this._selected.filter((entity) => {
- const stateObj = this.hass.states[entity];
- return stateObj?.attributes.restored;
+ private async _removeSelected() {
+ if (!this._entities || !this.hass) {
+ return;
+ }
+
+ const manifestsProm = this._manifests
+ ? undefined
+ : fetchIntegrationManifests(this.hass);
+ const helperDomains = [
+ ...new Set(this._selected.map((s) => computeDomain(s))),
+ ].filter((d) => isHelperDomain(d));
+
+ const configEntriesProm = this._entries
+ ? undefined
+ : this._loadConfigEntries();
+ const domainProms = helperDomains.map((d) =>
+ HELPERS_CRUD[d].fetch(this.hass)
+ );
+ const helpersResult = await Promise.all(domainProms);
+ let fetchedHelpers: Helper[] = [];
+ helpersResult.forEach((r) => {
+ fetchedHelpers = fetchedHelpers.concat(r);
});
+ if (manifestsProm) {
+ this._manifests = await manifestsProm;
+ }
+ if (configEntriesProm) {
+ await configEntriesProm;
+ }
+
+ const removeableEntities = this._selected.filter((entity_id) =>
+ isDeletableEntity(
+ this.hass,
+ entity_id,
+ this._manifests!,
+ this._entities,
+ this._entries!,
+ fetchedHelpers
+ )
+ );
showConfirmationDialog(this, {
title: this.hass.localize(
`ui.panel.config.entities.picker.delete_selected.confirm_title`
@@ -1305,8 +1352,15 @@ ${rejected
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
confirm: () => {
- removeableEntities.forEach((entity) =>
- removeEntityRegistryEntry(this.hass, entity)
+ removeableEntities.forEach((entity_id) =>
+ deleteEntity(
+ this.hass,
+ entity_id,
+ this._manifests!,
+ this._entities,
+ this._entries!,
+ fetchedHelpers
+ )
);
this._clearSelection();
},
diff --git a/src/panels/config/helpers/forms/dialog-schedule-block-info.ts b/src/panels/config/helpers/forms/dialog-schedule-block-info.ts
index 7063e57ab2a8..b4815eec1008 100644
--- a/src/panels/config/helpers/forms/dialog-schedule-block-info.ts
+++ b/src/panels/config/helpers/forms/dialog-schedule-block-info.ts
@@ -1,5 +1,6 @@
import type { CSSResultGroup } from "lit";
import { html, LitElement, nothing } from "lit";
+import memoizeOne from "memoize-one";
import { property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { createCloseHeading } from "../../../../components/ha-dialog";
@@ -13,19 +14,6 @@ import type {
} from "./show-dialog-schedule-block-info";
import type { SchemaUnion } from "../../../../components/ha-form/types";
-const SCHEMA = [
- {
- name: "from",
- required: true,
- selector: { time: { no_second: true } },
- },
- {
- name: "to",
- required: true,
- selector: { time: { no_second: true } },
- },
-];
-
class DialogScheduleBlockInfo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -35,10 +23,39 @@ class DialogScheduleBlockInfo extends LitElement {
@state() private _params?: ScheduleBlockInfoDialogParams;
+ private _expand = false;
+
+ private _schema = memoizeOne((expand: boolean) => [
+ {
+ name: "from",
+ required: true,
+ selector: { time: { no_second: true } },
+ },
+ {
+ name: "to",
+ required: true,
+ selector: { time: { no_second: true } },
+ },
+ {
+ name: "advanced_settings",
+ type: "expandable" as const,
+ flatten: true,
+ expanded: expand,
+ schema: [
+ {
+ name: "data",
+ required: false,
+ selector: { object: {} },
+ },
+ ],
+ },
+ ]);
+
public showDialog(params: ScheduleBlockInfoDialogParams): void {
this._params = params;
this._error = undefined;
this._data = params.block;
+ this._expand = !!params.block?.data;
}
public closeDialog(): void {
@@ -66,7 +83,7 @@ class DialogScheduleBlockInfo extends LitElement {
) => {
+ private _computeLabelCallback = (
+ schema: SchemaUnion>
+ ) => {
switch (schema.name) {
case "from":
return this.hass!.localize("ui.dialogs.helper_settings.schedule.start");
case "to":
return this.hass!.localize("ui.dialogs.helper_settings.schedule.end");
+ case "data":
+ return this.hass!.localize("ui.dialogs.helper_settings.schedule.data");
+ case "advanced_settings":
+ return this.hass!.localize(
+ "ui.dialogs.helper_settings.schedule.advanced_settings"
+ );
}
return "";
};
diff --git a/src/panels/config/helpers/forms/show-dialog-schedule-block-info.ts b/src/panels/config/helpers/forms/show-dialog-schedule-block-info.ts
index 14a59095924f..0a95789f86d9 100644
--- a/src/panels/config/helpers/forms/show-dialog-schedule-block-info.ts
+++ b/src/panels/config/helpers/forms/show-dialog-schedule-block-info.ts
@@ -3,6 +3,7 @@ import { fireEvent } from "../../../../common/dom/fire_event";
export interface ScheduleBlockInfo {
from: string;
to: string;
+ data?: Record;
}
export interface ScheduleBlockInfoDialogParams {
diff --git a/src/panels/config/helpers/ha-config-helpers.ts b/src/panels/config/helpers/ha-config-helpers.ts
index e73060c6f386..32b5854342ee 100644
--- a/src/panels/config/helpers/ha-config-helpers.ts
+++ b/src/panels/config/helpers/ha-config-helpers.ts
@@ -3,19 +3,23 @@ import { ResizeController } from "@lit-labs/observers/resize-controller";
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import {
mdiAlertCircle,
+ mdiCancel,
mdiChevronRight,
mdiCog,
mdiDotsVertical,
mdiMenuDown,
mdiPencilOff,
+ mdiProgressHelper,
mdiPlus,
mdiTag,
+ mdiTrashCan,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
+import { debounce } from "../../../common/util/debounce";
import { computeCssColor } from "../../../common/color/compute-color";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -54,7 +58,11 @@ import {
subscribeCategoryRegistry,
} from "../../../data/category_registry";
import type { ConfigEntry } from "../../../data/config_entries";
-import { subscribeConfigEntries } from "../../../data/config_entries";
+import {
+ ERROR_STATES,
+ deleteConfigEntry,
+ subscribeConfigEntries,
+} from "../../../data/config_entries";
import { getConfigFlowHandlers } from "../../../data/config_flow";
import { fullEntitiesContext } from "../../../data/context";
import type {
@@ -97,6 +105,7 @@ import { showAssignCategoryDialog } from "../category/show-dialog-assign-categor
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import { configSections } from "../ha-panel-config";
import "../integrations/ha-integration-overflow-menu";
+import { renderConfigEntryError } from "../integrations/ha-config-integration-page";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { isHelperDomain } from "./const";
import { showHelperDetailDialog } from "./show-dialog-helper-detail";
@@ -220,6 +229,12 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
callback: (entries) => entries[0]?.contentRect.width,
});
+ private _debouncedFetchEntitySources = debounce(
+ () => this._fetchEntitySources(),
+ 500,
+ false
+ );
+
public hassSubscribe() {
return [
subscribeConfigEntries(
@@ -236,6 +251,14 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
} else if (message.type === "updated") {
newEntries[message.entry.entry_id] = message.entry;
}
+ if (
+ this._entitySource &&
+ this._configEntries &&
+ message.entry.state === "loaded" &&
+ this._configEntries[message.entry.entry_id]?.state !== "loaded"
+ ) {
+ this._debouncedFetchEntitySources();
+ }
});
this._configEntries = newEntries;
},
@@ -352,6 +375,19 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
.hass=${this.hass}
narrow
.items=${[
+ ...(helper.configEntry &&
+ ERROR_STATES.includes(helper.configEntry.state)
+ ? [
+ {
+ path: mdiAlertCircle,
+ label: this.hass.localize(
+ "ui.panel.config.helpers.picker.error_information"
+ ),
+ warning: true,
+ action: () => this._showError(helper),
+ },
+ ]
+ : []),
{
path: mdiCog,
label: this.hass.localize(
@@ -366,6 +402,19 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
),
action: () => this._editCategory(helper),
},
+ ...(helper.configEntry &&
+ helper.editable &&
+ ERROR_STATES.includes(helper.configEntry.state) &&
+ helper.entity === undefined
+ ? [
+ {
+ path: mdiTrashCan,
+ label: this.hass.localize("ui.common.delete"),
+ warning: true,
+ action: () => this._deleteEntry(helper),
+ },
+ ]
+ : []),
]}
>
@@ -417,17 +466,27 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
};
});
- const entries = Object.values(configEntriesCopy).map((configEntry) => ({
- id: configEntry.entry_id,
- entity_id: "",
- icon: mdiAlertCircle,
- name: configEntry.title || "",
- editable: true,
- type: configEntry.domain,
- configEntry,
- entity: undefined,
- selectable: false,
- }));
+ const entries = Object.values(configEntriesCopy).map((configEntry) => {
+ const entityEntry = Object.values(entityEntries).find(
+ (entry) => entry.config_entry_id === configEntry.entry_id
+ );
+ const entityIsDisabled = !!entityEntry?.disabled_by;
+ return {
+ id: entityIsDisabled ? entityEntry.entity_id : configEntry.entry_id,
+ entity_id: entityIsDisabled ? entityEntry.entity_id : "",
+ icon: entityIsDisabled
+ ? mdiCancel
+ : configEntry.state === "setup_in_progress"
+ ? mdiProgressHelper
+ : mdiAlertCircle,
+ name: configEntry.title || "",
+ editable: true,
+ type: configEntry.domain,
+ configEntry,
+ entity: undefined,
+ selectable: entityIsDisabled,
+ };
+ });
return [...states, ...entries]
.filter((item) =>
@@ -1081,6 +1140,34 @@ ${rejected
}
}
+ private _showError(helper: HelperItem) {
+ showAlertDialog(this, {
+ title: this.hass.localize("ui.errors.config.configuration_error"),
+ text: renderConfigEntryError(this.hass, helper.configEntry!),
+ warning: true,
+ });
+ }
+
+ private async _deleteEntry(helper: HelperItem) {
+ const confirmed = await showConfirmationDialog(this, {
+ title: this.hass.localize(
+ "ui.panel.config.integrations.config_entry.delete_confirm_title",
+ { title: helper.configEntry!.title }
+ ),
+ text: this.hass.localize(
+ "ui.panel.config.integrations.config_entry.delete_confirm_text"
+ ),
+ confirmText: this.hass!.localize("ui.common.delete"),
+ dismissText: this.hass!.localize("ui.common.cancel"),
+ destructive: true,
+ });
+
+ if (!confirmed) {
+ return;
+ }
+ deleteConfigEntry(this.hass, helper.id);
+ }
+
private _openSettings(helper: HelperItem) {
if (helper.entity) {
showMoreInfoDialog(this, {
diff --git a/src/panels/config/integrations/ha-config-integration-page.ts b/src/panels/config/integrations/ha-config-integration-page.ts
index 876c3e59d89d..9beef8810663 100644
--- a/src/panels/config/integrations/ha-config-integration-page.ts
+++ b/src/panels/config/integrations/ha-config-integration-page.ts
@@ -106,6 +106,38 @@ import { fileDownload } from "../../../util/file_download";
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
import { showAddIntegrationDialog } from "./show-add-integration-dialog";
+export const renderConfigEntryError = (
+ hass: HomeAssistant,
+ entry: ConfigEntry
+): TemplateResult => {
+ if (entry.reason) {
+ if (entry.error_reason_translation_key) {
+ const lokalisePromExc = hass
+ .loadBackendTranslation("exceptions", entry.domain)
+ .then(
+ (localize) =>
+ localize(
+ `component.${entry.domain}.exceptions.${entry.error_reason_translation_key}.message`,
+ entry.error_reason_translation_placeholders ?? undefined
+ ) || entry.reason
+ );
+ return html`${until(lokalisePromExc)}`;
+ }
+ const lokalisePromError = hass
+ .loadBackendTranslation("config", entry.domain)
+ .then(
+ (localize) =>
+ localize(`component.${entry.domain}.config.error.${entry.reason}`) ||
+ entry.reason
+ );
+ return html`${until(lokalisePromError, entry.reason)}`;
+ }
+ return html`
+
+ ${hass.localize("ui.panel.config.integrations.config_entry.check_the_logs")}
+ `;
+};
+
@customElement("ha-config-integration-page")
class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -618,37 +650,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
stateText = [
`ui.panel.config.integrations.config_entry.state.${item.state}`,
];
- if (item.reason) {
- if (item.error_reason_translation_key) {
- const lokalisePromExc = this.hass
- .loadBackendTranslation("exceptions", item.domain)
- .then(
- (localize) =>
- localize(
- `component.${item.domain}.exceptions.${item.error_reason_translation_key}.message`,
- item.error_reason_translation_placeholders ?? undefined
- ) || item.reason
- );
- stateTextExtra = html`${until(lokalisePromExc)}`;
- } else {
- const lokalisePromError = this.hass
- .loadBackendTranslation("config", item.domain)
- .then(
- (localize) =>
- localize(
- `component.${item.domain}.config.error.${item.reason}`
- ) || item.reason
- );
- stateTextExtra = html`${until(lokalisePromError, item.reason)}`;
- }
- } else {
- stateTextExtra = html`
-
- ${this.hass.localize(
- "ui.panel.config.integrations.config_entry.check_the_logs"
- )}
- `;
- }
+ stateTextExtra = renderConfigEntryError(this.hass, item);
}
const devices = this._getConfigEntryDevices(item);
diff --git a/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-color-switch.ts b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-color-switch.ts
new file mode 100644
index 000000000000..69ee23b3f16f
--- /dev/null
+++ b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-color-switch.ts
@@ -0,0 +1,98 @@
+import { LitElement, html } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import type { DeviceRegistryEntry } from "../../../../../../data/device_registry";
+import type { HomeAssistant } from "../../../../../../types";
+import { invokeZWaveCCApi } from "../../../../../../data/zwave_js";
+import "../../../../../../components/ha-alert";
+import "../../../../../../components/ha-circular-progress";
+import { extractApiErrorMessage } from "../../../../../../data/hassio/common";
+import "./zwave_js-capability-control-multilevel-switch";
+
+enum ColorComponent {
+ "Warm White" = 0,
+ "Cold White",
+ Red,
+ Green,
+ Blue,
+ Amber,
+ Cyan,
+ Purple,
+ Index,
+}
+
+@customElement("zwave_js-capability-control-color_switch")
+class ZWaveJSCapabilityColorSwitch extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public device!: DeviceRegistryEntry;
+
+ @property({ type: Number }) public endpoint!: number;
+
+ @property({ type: Number }) public command_class!: number;
+
+ @property({ type: Number }) public version!: number;
+
+ @state() private _color_components?: ColorComponent[];
+
+ @state() private _error?: string;
+
+ protected render() {
+ if (this._error) {
+ return html`${this._error}`;
+ }
+ if (!this._color_components) {
+ return html``;
+ }
+ return this._color_components.map(
+ (color) =>
+ html`
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.capability_controls.color_switch.color_component"
+ )}:
+ ${this.hass.localize(
+ `ui.panel.config.zwave_js.node_installer.capability_controls.color_switch.colors.${color}`
+ )}
+
+ `
+ );
+ }
+
+ protected async firstUpdated() {
+ try {
+ this._color_components = (await invokeZWaveCCApi(
+ this.hass,
+ this.device.id,
+ this.command_class,
+ this.endpoint,
+ "getSupported",
+ [],
+ true
+ )) as number[];
+ } catch (error) {
+ this._error = extractApiErrorMessage(error);
+ }
+ }
+
+ private _transformOptions(color: number) {
+ return (opts: Record, control: string) =>
+ control === "startLevelChange"
+ ? {
+ ...opts,
+ colorComponent: color,
+ }
+ : color;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "zwave_js-capability-control-color_switch": ZWaveJSCapabilityColorSwitch;
+ }
+}
diff --git a/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-multilevel-switch.ts b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-multilevel-switch.ts
new file mode 100644
index 000000000000..9a06049121fe
--- /dev/null
+++ b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-multilevel-switch.ts
@@ -0,0 +1,167 @@
+import { LitElement, css, html } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import "../../../../../../components/buttons/ha-progress-button";
+import type { DeviceRegistryEntry } from "../../../../../../data/device_registry";
+import type { HomeAssistant } from "../../../../../../types";
+import { invokeZWaveCCApi } from "../../../../../../data/zwave_js";
+import "../../../../../../components/ha-textfield";
+import "../../../../../../components/ha-select";
+import "../../../../../../components/ha-list-item";
+import "../../../../../../components/ha-alert";
+import "../../../../../../components/ha-formfield";
+import "../../../../../../components/ha-switch";
+import type { HaProgressButton } from "../../../../../../components/buttons/ha-progress-button";
+import type { HaSelect } from "../../../../../../components/ha-select";
+import type { HaTextField } from "../../../../../../components/ha-textfield";
+import type { HaSwitch } from "../../../../../../components/ha-switch";
+import { extractApiErrorMessage } from "../../../../../../data/hassio/common";
+
+@customElement("zwave_js-capability-control-multilevel_switch")
+class ZWaveJSCapabilityMultiLevelSwitch extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public device!: DeviceRegistryEntry;
+
+ @property({ type: Number }) public endpoint!: number;
+
+ @property({ type: Number }) public command_class!: number;
+
+ @property({ type: Number }) public version!: number;
+
+ @property({ attribute: false }) public transform_options?: (
+ opts: Record,
+ control: string
+ ) => unknown;
+
+ @state() private _error?: string;
+
+ protected render() {
+ return html`
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.title"
+ )}
+
+ ${this._error
+ ? html`${this._error}`
+ : ""}
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.up"
+ )}
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.down"
+ )}
+
+
+
+
+
+
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.start_transition"
+ )}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.stop_transition"
+ )}
+
+
+ `;
+ }
+
+ private async _controlTransition(ev: any) {
+ const control = ev.currentTarget!.control;
+ const button = ev.currentTarget as HaProgressButton;
+ button.progress = true;
+
+ const direction = (this.shadowRoot!.getElementById("direction") as HaSelect)
+ .value;
+
+ const ignoreStartLevel = (
+ this.shadowRoot!.getElementById("ignore_start_level") as HaSwitch
+ ).checked;
+
+ const startLevel = Number(
+ (this.shadowRoot!.getElementById("start_level") as HaTextField).value
+ );
+
+ const options = {
+ direction,
+ ignoreStartLevel,
+ startLevel,
+ };
+
+ try {
+ button.actionSuccess();
+ await invokeZWaveCCApi(
+ this.hass,
+ this.device.id,
+ this.command_class,
+ this.endpoint,
+ control,
+ [
+ this.transform_options
+ ? this.transform_options(options, control)
+ : options,
+ ],
+ true
+ );
+ } catch (err) {
+ button.actionError();
+ this._error = this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.control_failed",
+ { error: extractApiErrorMessage(err) }
+ );
+ }
+
+ button.progress = false;
+ }
+
+ static styles = css`
+ ha-select,
+ ha-formfield,
+ ha-textfield {
+ display: block;
+ margin-bottom: 8px;
+ }
+ .actions {
+ display: flex;
+ justify-content: flex-end;
+ }
+ `;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "zwave_js-capability-control-multilevel_switch": ZWaveJSCapabilityMultiLevelSwitch;
+ }
+}
diff --git a/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-thermostat-setback.ts b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-thermostat-setback.ts
new file mode 100644
index 000000000000..784a4b8e07b7
--- /dev/null
+++ b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-thermostat-setback.ts
@@ -0,0 +1,241 @@
+import { LitElement, css, html } from "lit";
+import { customElement, property, query, state } from "lit/decorators";
+import type { DeviceRegistryEntry } from "../../../../../../data/device_registry";
+import type { HomeAssistant } from "../../../../../../types";
+import { invokeZWaveCCApi } from "../../../../../../data/zwave_js";
+import "../../../../../../components/ha-button";
+import "../../../../../../components/buttons/ha-progress-button";
+import "../../../../../../components/ha-textfield";
+import "../../../../../../components/ha-select";
+import "../../../../../../components/ha-list-item";
+import "../../../../../../components/ha-alert";
+import type { HaSelect } from "../../../../../../components/ha-select";
+import type { HaTextField } from "../../../../../../components/ha-textfield";
+import { extractApiErrorMessage } from "../../../../../../data/hassio/common";
+import type { HaProgressButton } from "../../../../../../components/buttons/ha-progress-button";
+
+// enum with special states
+enum SpecialState {
+ frost_protection = "Frost Protection",
+ energy_saving = "Energy Saving",
+ unused = "Unused",
+}
+
+const SETBACK_TYPE_OPTIONS = ["none", "temporary", "permanent"];
+
+@customElement("zwave_js-capability-control-thermostat_setback")
+class ZWaveJSCapabilityThermostatSetback extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public device!: DeviceRegistryEntry;
+
+ @property({ type: Number }) public endpoint!: number;
+
+ @property({ type: Number }) public command_class!: number;
+
+ @property({ type: Number }) public version!: number;
+
+ @state() private _disableSetbackState = false;
+
+ @query("#setback_type") private _setbackTypeInput!: HaSelect;
+
+ @query("#setback_state") private _setbackStateInput!: HaTextField;
+
+ @query("#setback_special_state")
+ private _setbackSpecialStateSelect!: HaSelect;
+
+ @state() private _error?: string;
+
+ @state() private _loading = true;
+
+ protected render() {
+ return html`
+
+ ${this.hass.localize(
+ `ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.title`
+ )}
+
+ ${this._error
+ ? html`${this._error}`
+ : ""}
+
+ ${SETBACK_TYPE_OPTIONS.map(
+ (translationKey, index) =>
+ html`
+ ${this.hass.localize(
+ `ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.setback_type.${translationKey}`
+ )}
+ `
+ )}
+
+
+
+
+
+ ${Object.entries(SpecialState).map(
+ ([translationKey, value]) =>
+ html`
+ ${this.hass.localize(
+ `ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.setback_special_state.${translationKey}`
+ )}
+ `
+ )}
+
+
+
+ ${this.hass.localize("ui.common.clear")}
+
+ ${this.hass.localize("ui.common.save")}
+
+
+ `;
+ }
+
+ protected firstUpdated() {
+ this._loadSetback();
+ }
+
+ private async _loadSetback() {
+ this._loading = true;
+ try {
+ const { setbackType, setbackState } = (await invokeZWaveCCApi(
+ this.hass,
+ this.device.id,
+ this.command_class,
+ this.endpoint,
+ "get",
+ [],
+ true
+ )) as { setbackType: number; setbackState: number | SpecialState };
+
+ this._setbackTypeInput.value = String(setbackType);
+ if (typeof setbackState === "number") {
+ this._setbackStateInput.value = String(setbackState);
+ this._setbackSpecialStateSelect.value = "";
+ } else {
+ this._setbackSpecialStateSelect.value = setbackState;
+ }
+ } catch (err) {
+ this._error = this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.get_setback_failed",
+ { error: extractApiErrorMessage(err) }
+ );
+ }
+
+ this._loading = false;
+ }
+
+ private _changeSpecialState() {
+ this._disableSetbackState = !!this._setbackSpecialStateSelect.value;
+ }
+
+ private async _saveSetback(ev: CustomEvent) {
+ const button = ev.currentTarget as HaProgressButton;
+ button.progress = true;
+
+ this._error = undefined;
+ const setbackType = this._setbackTypeInput.value;
+
+ let setbackState: number | string = Number(this._setbackStateInput.value);
+ if (this._setbackSpecialStateSelect.value) {
+ setbackState = this._setbackSpecialStateSelect.value;
+ }
+
+ try {
+ await invokeZWaveCCApi(
+ this.hass,
+ this.device.id,
+ this.command_class,
+ this.endpoint,
+ "set",
+ [Number(setbackType), setbackState],
+ true
+ );
+
+ button.actionSuccess();
+ } catch (err) {
+ button.actionError();
+ this._error = this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.save_setback_failed",
+ { error: extractApiErrorMessage(err) }
+ );
+ }
+
+ button.progress = false;
+ }
+
+ private _clear() {
+ this._loadSetback();
+ }
+
+ static styles = css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 16px;
+ }
+ :host > ha-select {
+ width: 100%;
+ }
+ .actions {
+ width: 100%;
+ display: flex;
+ justify-content: flex-end;
+ }
+ .actions .clear-button {
+ --mdc-theme-primary: var(--red-color);
+ }
+ .setback-state {
+ width: 100%;
+ display: flex;
+ gap: 16px;
+ }
+ .setback-state ha-select,
+ ha-textfield {
+ flex: 1;
+ }
+ `;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "zwave_js-capability-control-thermostat_setback": ZWaveJSCapabilityThermostatSetback;
+ }
+}
diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-add-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-add-node.ts
index 565478d98247..2b8435f0bb30 100644
--- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-add-node.ts
+++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-add-node.ts
@@ -851,7 +851,7 @@ class DialogZWaveJSAddNode extends LitElement {
this._addNodeTimeoutHandle = window.setTimeout(() => {
this._unsubscribe();
this._status = "timed_out";
- }, 90000);
+ }, 300000);
}
private _onBeforeUnload = (event: BeforeUnloadEvent) => {
diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts
index 0666dedf4783..008f8a514958 100644
--- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts
+++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts
@@ -48,6 +48,10 @@ class ZWaveJSConfigRouter extends HassRouterPage {
tag: "zwave_js-node-config",
load: () => import("./zwave_js-node-config"),
},
+ node_installer: {
+ tag: "zwave_js-node-installer",
+ load: () => import("./zwave_js-node-installer"),
+ },
logs: {
tag: "zwave_js-logs",
load: () => import("./zwave_js-logs"),
diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-custom-param.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-custom-param.ts
new file mode 100644
index 000000000000..15364e9ae520
--- /dev/null
+++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-custom-param.ts
@@ -0,0 +1,264 @@
+import { LitElement, html, css, type CSSResultGroup, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { mdiCloseCircle } from "@mdi/js";
+import "../../../../../components/ha-textfield";
+import "../../../../../components/ha-select";
+import "../../../../../components/ha-button";
+import "../../../../../components/ha-circular-progress";
+import "../../../../../components/ha-list-item";
+import type { HomeAssistant } from "../../../../../types";
+import {
+ getZwaveNodeRawConfigParameter,
+ setZwaveNodeRawConfigParameter,
+} from "../../../../../data/zwave_js";
+
+@customElement("zwave_js-custom-param")
+class ZWaveJSCustomParam extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property() public deviceId!: string;
+
+ @state() private _customParamNumber?: number;
+
+ @state() private _valueSize = 1;
+
+ @state() private _value?: number;
+
+ @state() private _valueFormat = 0;
+
+ @state() private _isLoading = false;
+
+ @state() private _error = "";
+
+ protected render() {
+ return html`
+
+
+
+ 1
+ 2
+ 4
+
+
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.signed"
+ )}
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.unsigned"
+ )}
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.enumerated"
+ )}
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.bitfield"
+ )}
+
+
+
+ ${this._isLoading
+ ? html``
+ : nothing}
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.get_value"
+ )}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.set_value"
+ )}
+
+
+
+ ${this._error
+ ? html`${this._error}`
+ : nothing}
+
+ `;
+ }
+
+ private _tryParseNumber(value: string): number | undefined {
+ if (!value) return undefined;
+ const parsed = Number(value);
+ if (Number.isNaN(parsed)) return undefined;
+ return parsed;
+ }
+
+ private _customParamNumberChanged(ev: Event) {
+ this._customParamNumber = this._tryParseNumber(
+ (ev.target as HTMLInputElement).value
+ );
+ }
+
+ private _customValueSizeChanged(ev: Event) {
+ this._valueSize =
+ this._tryParseNumber((ev.target as HTMLSelectElement).value) ?? 1;
+ }
+
+ private _customValueChanged(ev: Event) {
+ this._value = this._tryParseNumber((ev.target as HTMLInputElement).value);
+ }
+
+ private _customValueFormatChanged(ev: Event) {
+ this._valueFormat =
+ this._tryParseNumber((ev.target as HTMLSelectElement).value) ?? 0;
+ }
+
+ private async _getCustomConfigValue() {
+ if (this._customParamNumber === undefined) {
+ this._error = this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.error_required",
+ {
+ entity: this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.parameter"
+ ),
+ }
+ );
+ return;
+ }
+ this._error = "";
+ this._isLoading = true;
+ try {
+ const value = await getZwaveNodeRawConfigParameter(
+ this.hass,
+ this.deviceId,
+ this._customParamNumber
+ );
+ this._value = value;
+ } catch (err: any) {
+ this._error = err?.message || "Unknown error";
+ } finally {
+ this._isLoading = false;
+ }
+ }
+
+ private async _setCustomConfigValue() {
+ if (this._customParamNumber === undefined) {
+ this._error = this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.error_required",
+ {
+ entity: this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.parameter"
+ ),
+ }
+ );
+ return;
+ }
+ if (this._value === undefined) {
+ this._error = this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.error_required",
+ {
+ entity: this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.value"
+ ),
+ }
+ );
+ return;
+ }
+ this._error = "";
+ this._isLoading = true;
+ try {
+ await setZwaveNodeRawConfigParameter(
+ this.hass,
+ this.deviceId,
+ this._customParamNumber,
+ this._value,
+ this._valueSize,
+ this._valueFormat
+ );
+ } catch (err: any) {
+ this._error = err?.message || "Unknown error";
+ } finally {
+ this._isLoading = false;
+ }
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ .custom-config-form {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ margin-bottom: 8px;
+ }
+
+ ha-textfield,
+ ha-select {
+ flex-grow: 1;
+ flex-basis: calc(50% - 8px);
+ min-width: 120px;
+ }
+
+ @media (min-width: 681px) {
+ .custom-config-form {
+ flex-wrap: nowrap;
+ }
+
+ ha-textfield,
+ ha-select {
+ flex-basis: 0;
+ }
+ }
+
+ .custom-config-buttons {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ }
+
+ .error {
+ color: var(--error-color);
+ }
+
+ .error-icon {
+ margin-right: 8px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "zwave_js-custom-param": ZWaveJSCustomParam;
+ }
+}
diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts
index 91be0fa47003..30f1cde47baf 100644
--- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts
+++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts
@@ -13,22 +13,26 @@ import { classMap } from "lit/directives/class-map";
import { groupBy } from "../../../../../common/util/group-by";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-card";
-import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-select";
import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-textfield";
import "../../../../../components/ha-selector/ha-selector-boolean";
+import "../../../../../components/buttons/ha-progress-button";
+import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
import { computeDeviceName } from "../../../../../data/device_registry";
import type {
+ ZWaveJSNodeCapabilities,
ZWaveJSNodeConfigParam,
ZWaveJSNodeConfigParams,
ZWaveJSSetConfigParamResult,
ZwaveJSNodeMetadata,
} from "../../../../../data/zwave_js";
import {
+ fetchZwaveNodeCapabilities,
fetchZwaveNodeConfigParameters,
fetchZwaveNodeMetadata,
+ invokeZWaveCCApi,
setZwaveNodeConfigParameter,
} from "../../../../../data/zwave_js";
import "../../../../../layouts/hass-error-screen";
@@ -38,6 +42,9 @@ import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import "../../../ha-config-section";
import { configTabs } from "./zwave_js-config-router";
+import "./zwave_js-custom-param";
+import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
+import { fireEvent } from "../../../../../common/dom/fire_event";
const icons = {
accepted: mdiCheckCircle,
@@ -63,10 +70,14 @@ class ZWaveJSNodeConfig extends LitElement {
@state() private _config?: ZWaveJSNodeConfigParams;
+ @state() private _canResetAll = false;
+
@state() private _results: Record = {};
@state() private _error?: string;
+ @state() private _resetDialogProgress = false;
+
public connectedCallback(): void {
super.connectedCallback();
this.deviceId = this.route.path.substr(1);
@@ -94,6 +105,8 @@ class ZWaveJSNodeConfig extends LitElement {
const device = this.hass.devices[this.deviceId];
+ const deviceName = device ? computeDeviceName(device, this.hass) : "";
+
return html`
- ${computeDeviceName(device, this.hass)}
+ ${deviceName}
${device.manufacturer} ${device.model}
`
@@ -174,6 +187,35 @@ class ZWaveJSNodeConfig extends LitElement {
`
)}
+ ${this._canResetAll
+ ? html`
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.reset_to_default.button_label"
+ )}
+
+
`
+ : nothing}
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.custom_config"
+ )}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.custom_config_description"
+ )}
+
+
+
+
`;
@@ -432,10 +474,79 @@ class ZWaveJSNodeConfig extends LitElement {
return;
}
- [this._nodeMetadata, this._config] = await Promise.all([
+ let capabilities: ZWaveJSNodeCapabilities | undefined;
+ [this._nodeMetadata, this._config, capabilities] = await Promise.all([
fetchZwaveNodeMetadata(this.hass, device.id),
fetchZwaveNodeConfigParameters(this.hass, device.id),
+ fetchZwaveNodeCapabilities(this.hass, device.id),
]);
+ this._canResetAll =
+ capabilities &&
+ Object.values(capabilities).some((endpoint) =>
+ endpoint.some(
+ (capability) => capability.id === 0x70 && capability.version >= 4
+ )
+ );
+ }
+
+ private async _openResetDialog(event: Event) {
+ const progressButton = event.currentTarget as HaProgressButton;
+
+ await showConfirmationDialog(this, {
+ destructive: true,
+ title: this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.reset_to_default.dialog.title"
+ ),
+ text: this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.reset_to_default.dialog.text"
+ ),
+ confirmText: this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.reset_to_default.dialog.reset"
+ ),
+ confirm: () => this._resetAllConfigParameters(progressButton),
+ });
+ }
+
+ private async _resetAllConfigParameters(progressButton: HaProgressButton) {
+ this._resetDialogProgress = true;
+ fireEvent(this, "hass-notification", {
+ message: this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.reset_to_default.dialog.text_loading"
+ ),
+ });
+
+ try {
+ const device = this.hass.devices[this.deviceId];
+ if (!device) {
+ throw new Error("device_not_found");
+ }
+ await invokeZWaveCCApi(
+ this.hass,
+ device.id,
+ 0x70, // 0x70 is the command class for Configuration
+ undefined,
+ "resetAll",
+ [],
+ true
+ );
+
+ fireEvent(this, "hass-notification", {
+ message: this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.reset_to_default.dialog.text_success"
+ ),
+ });
+
+ await this._fetchData();
+ progressButton.actionSuccess();
+ } catch (err: any) {
+ fireEvent(this, "hass-notification", {
+ message: this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.reset_to_default.dialog.text_error"
+ ),
+ });
+ progressButton.actionError();
+ }
+ this._resetDialogProgress = false;
}
static get styles(): CSSResultGroup {
@@ -520,6 +631,16 @@ class ZWaveJSNodeConfig extends LitElement {
.switch {
text-align: right;
}
+
+ .custom-config {
+ padding: 16px;
+ }
+
+ .reset {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 24px;
+ }
`,
];
}
diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-installer.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-installer.ts
new file mode 100644
index 000000000000..03596963cb56
--- /dev/null
+++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-installer.ts
@@ -0,0 +1,212 @@
+import "@material/mwc-button/mwc-button";
+import "@material/mwc-list/mwc-list-item";
+import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
+import { LitElement, css, html, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { dynamicElement } from "../../../../../common/dom/dynamic-element-directive";
+import "../../../../../components/ha-card";
+import { computeDeviceName } from "../../../../../data/device_registry";
+import type {
+ ZWaveJSNodeCapabilities,
+ ZwaveJSNodeMetadata,
+} from "../../../../../data/zwave_js";
+import {
+ fetchZwaveNodeCapabilities,
+ fetchZwaveNodeMetadata,
+} from "../../../../../data/zwave_js";
+import "../../../../../layouts/hass-error-screen";
+import "../../../../../layouts/hass-loading-screen";
+import "../../../../../layouts/hass-subpage";
+import { haStyle } from "../../../../../resources/styles";
+import type { HomeAssistant, Route } from "../../../../../types";
+import "../../../ha-config-section";
+import "./capability-controls/zwave_js-capability-control-multilevel-switch";
+import "./capability-controls/zwave_js-capability-control-thermostat-setback";
+import "./capability-controls/zwave_js-capability-control-color-switch";
+
+const CAPABILITY_CONTROLS = {
+ 38: "multilevel_switch",
+ 71: "thermostat_setback",
+ 51: "color_switch",
+};
+
+@customElement("zwave_js-node-installer")
+class ZWaveJSNodeInstaller extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public route!: Route;
+
+ @property({ type: Boolean }) public narrow = false;
+
+ @property({ type: Boolean }) public isWide = false;
+
+ @property() public configEntryId?: string;
+
+ @property() public deviceId!: string;
+
+ @state() private _nodeMetadata?: ZwaveJSNodeMetadata;
+
+ @state() private _capabilities?: ZWaveJSNodeCapabilities;
+
+ @state() private _error?: string;
+
+ public connectedCallback(): void {
+ super.connectedCallback();
+ this.deviceId = this.route.path.substr(1);
+ }
+
+ protected updated(changedProps: PropertyValues): void {
+ if (!this._capabilities || changedProps.has("deviceId")) {
+ this._fetchData();
+ }
+ }
+
+ protected render(): TemplateResult {
+ if (this._error) {
+ return html``;
+ }
+
+ if (!this._capabilities || !this._nodeMetadata) {
+ return html``;
+ }
+
+ const device = this.hass.devices[this.deviceId];
+
+ const endpoints = Object.entries(this._capabilities).filter(
+ ([_endpoint, capabilities]) => {
+ const filteredCapabilities = capabilities.filter(
+ (capability) => capability.id in CAPABILITY_CONTROLS
+ );
+ return filteredCapabilities.length > 0;
+ }
+ );
+
+ return html`
+
+
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.header"
+ )}
+
+
+
+ ${device
+ ? html`
+
+
${computeDeviceName(device, this.hass)}
+
${device.manufacturer} ${device.model}
+
+ `
+ : ``}
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.introduction"
+ )}
+
+ ${endpoints.length
+ ? endpoints.map(
+ ([endpoint, capabilities]) => html`
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.endpoint"
+ )}:
+ ${endpoint}
+
+
+ ${capabilities.map(
+ (capability) => html`
+ ${capability.id in CAPABILITY_CONTROLS
+ ? html`
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.command_class"
+ )}:
+ ${capability.name}
+
+ ${dynamicElement(
+ `zwave_js-capability-control-${CAPABILITY_CONTROLS[capability.id]}`,
+ {
+ hass: this.hass,
+ device: device,
+ endpoint: endpoint,
+ command_class: capability.id,
+ version: capability.version,
+ is_secure: capability.is_secure,
+ }
+ )}
+ `
+ : nothing}
+ `
+ )}
+
+ `
+ )
+ : html`${this.hass.localize(
+ "ui.panel.config.zwave_js.node_installer.no_settings"
+ )}`}
+
+
+ `;
+ }
+
+ private async _fetchData() {
+ if (!this.configEntryId) {
+ return;
+ }
+
+ const device = this.hass.devices[this.deviceId];
+ if (!device) {
+ this._error = "device_not_found";
+ return;
+ }
+
+ [this._nodeMetadata, this._capabilities] = await Promise.all([
+ fetchZwaveNodeMetadata(this.hass, device.id),
+ fetchZwaveNodeCapabilities(this.hass, device.id),
+ ]);
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ haStyle,
+ css`
+ ha-card {
+ margin-bottom: 40px;
+ margin-top: 0;
+ }
+ .capability {
+ border-bottom: 1px solid var(--divider-color);
+ padding: 4px 16px;
+ }
+ .capability:last-child {
+ border-bottom: none;
+ }
+ .empty {
+ margin-top: 32px;
+ padding: 24px 16px;
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "zwave_js-node-installer": ZWaveJSNodeInstaller;
+ }
+}
diff --git a/src/panels/config/labels/ha-config-labels.ts b/src/panels/config/labels/ha-config-labels.ts
index 1d974bb7a582..2e52ddb6637e 100644
--- a/src/panels/config/labels/ha-config-labels.ts
+++ b/src/panels/config/labels/ha-config-labels.ts
@@ -106,7 +106,8 @@ export class HaConfigLabels extends LitElement {
style="
background-color: ${computeCssColor(label.color)};
border-radius: 10px;
- outline: 1px solid var(--outline-color);
+ border: 1px solid var(--outline-color);
+ box-sizing: border-box;
width: 20px;
height: 20px;"
>
`
diff --git a/src/panels/config/logs/dialog-download-logs.ts b/src/panels/config/logs/dialog-download-logs.ts
index 0a7e9d965f40..801ef1db2262 100644
--- a/src/panels/config/logs/dialog-download-logs.ts
+++ b/src/panels/config/logs/dialog-download-logs.ts
@@ -17,19 +17,21 @@ import type { HomeAssistant } from "../../../types";
import { fileDownload } from "../../../util/file_download";
import type { DownloadLogsDialogParams } from "./show-dialog-download-logs";
+const DEFAULT_LINE_COUNT = 500;
+
@customElement("dialog-download-logs")
class DownloadLogsDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _dialogParams?: DownloadLogsDialogParams;
- @state() private _lineCount = 100;
+ @state() private _lineCount = DEFAULT_LINE_COUNT;
@query("ha-md-dialog") private _dialogElement!: HaMdDialog;
public showDialog(dialogParams: DownloadLogsDialogParams) {
this._dialogParams = dialogParams;
- this._lineCount = this._dialogParams?.defaultLineCount ?? 100;
+ this._lineCount = this._dialogParams?.defaultLineCount || 500;
}
public closeDialog() {
@@ -38,7 +40,7 @@ class DownloadLogsDialog extends LitElement {
private _dialogClosed() {
this._dialogParams = undefined;
- this._lineCount = 100;
+ this._lineCount = DEFAULT_LINE_COUNT;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -48,7 +50,7 @@ class DownloadLogsDialog extends LitElement {
}
const numberOfLinesOptions = [100, 500, 1000, 5000, 10000];
- if (!numberOfLinesOptions.includes(this._lineCount)) {
+ if (!numberOfLinesOptions.includes(this._lineCount) && this._lineCount) {
numberOfLinesOptions.push(this._lineCount);
numberOfLinesOptions.sort((a, b) => a - b);
}
@@ -63,7 +65,7 @@ class DownloadLogsDialog extends LitElement {
.path=${mdiClose}
>
- ${this.hass.localize("ui.panel.config.logs.download_full_log")}
+ ${this.hass.localize("ui.panel.config.logs.download_logs")}
${this._dialogParams.header}${this._dialogParams.boot === 0
@@ -95,7 +97,7 @@ class DownloadLogsDialog extends LitElement {
${this.hass.localize("ui.common.cancel")}
-
+
${this.hass.localize("ui.common.download")}
@@ -103,7 +105,7 @@ class DownloadLogsDialog extends LitElement {
`;
}
- private async _dowloadLogs() {
+ private async _downloadLogs() {
const provider = this._dialogParams!.provider;
const boot = this._dialogParams!.boot;
diff --git a/src/panels/config/logs/error-log-card.ts b/src/panels/config/logs/error-log-card.ts
index 259a87ca7d12..a5bd9e0b5f5c 100644
--- a/src/panels/config/logs/error-log-card.ts
+++ b/src/panels/config/logs/error-log-card.ts
@@ -1,9 +1,17 @@
import "@material/mwc-list/mwc-list-item";
+import type { ActionDetail } from "@material/mwc-list";
+
import {
mdiArrowCollapseDown,
+ mdiDotsVertical,
+ mdiCircle,
mdiDownload,
+ mdiFormatListNumbered,
mdiMenuDown,
mdiRefresh,
+ mdiWrap,
+ mdiWrapDisabled,
+ mdiFolderTextOutline,
} from "@mdi/js";
import {
css,
@@ -31,6 +39,8 @@ import "../../../components/chips/ha-assist-chip";
import "../../../components/ha-menu";
import "../../../components/ha-md-menu-item";
import "../../../components/ha-md-divider";
+import "../../../components/ha-button-menu";
+import "../../../components/ha-list-item";
import { getSignedPath } from "../../../data/auth";
@@ -40,17 +50,23 @@ import {
fetchHassioBoots,
fetchHassioLogs,
fetchHassioLogsFollow,
+ fetchHassioLogsLegacy,
+ getHassioLogDownloadLinesUrl,
getHassioLogDownloadUrl,
} from "../../../data/hassio/supervisor";
import type { HomeAssistant } from "../../../types";
-import { fileDownload } from "../../../util/file_download";
-import type { HASSDomEvent } from "../../../common/dom/fire_event";
+import {
+ downloadFileSupported,
+ fileDownload,
+} from "../../../util/file_download";
+import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import type { ConnectionStatus } from "../../../data/connection-status";
import { atLeastVersion } from "../../../common/config/version";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { debounce } from "../../../common/util/debounce";
import { showDownloadLogsDialog } from "./show-dialog-download-logs";
import type { HaMenu } from "../../../components/ha-menu";
+import type { LocalizeFunc } from "../../../common/translations/localize";
const NUMBER_OF_LINES = 100;
@@ -58,13 +74,16 @@ const NUMBER_OF_LINES = 100;
class ErrorLogCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
+ @property({ attribute: false }) public localizeFunc?: LocalizeFunc
;
+
@property() public filter = "";
@property() public header?: string;
- @property() public provider!: string;
+ @property() public provider?: string;
- @property({ type: Boolean, attribute: true }) public show = false;
+ @property({ attribute: "allow-switch", type: Boolean }) public allowSwitch =
+ false;
@query(".error-log") private _logElement?: HTMLElement;
@@ -109,30 +128,48 @@ class ErrorLogCard extends LitElement {
@state() private _boots?: number[];
+ @state() private _showBootsSelect = false;
+
+ @state() private _wrapLines = true;
+
+ @state() private _downloadSupported?: boolean;
+
+ @state() private _logsFileLink?: string;
+
protected render(): TemplateResult {
+ const streaming =
+ this._streamSupported &&
+ this.provider &&
+ isComponentLoaded(this.hass, "hassio") &&
+ this._loadingState !== "loading";
+
+ const hasBoots = this._streamSupported && Array.isArray(this._boots);
+
+ const localize = this.localizeFunc || this.hass.localize;
return html`
${this._error
? html`
${this._error}`
- : ""}
-
+ : nothing}
+
@@ -202,25 +289,22 @@ class ErrorLogCard extends LitElement {
`
: nothing}
${this._loadingState === "loading"
- ? html`
- ${this.hass.localize("ui.panel.config.logs.loading_log")}
-
`
+ ? html`${localize("ui.panel.config.logs.loading_log")}
`
: this._loadingState === "empty"
- ? html`
- ${this.hass.localize("ui.panel.config.logs.no_errors")}
-
`
+ ? html`${localize("ui.panel.config.logs.no_errors")}
`
: nothing}
${this._loadingState === "loaded" &&
this.filter &&
this._noSearchResults
? html`
- ${this.hass.localize(
- "ui.panel.config.logs.no_issues_search",
- { term: this.filter }
- )}
+ ${localize("ui.panel.config.logs.no_issues_search", {
+ term: this.filter,
+ })}
`
: nothing}
-
+
- ${this.hass.localize("ui.panel.config.logs.scroll_down_button")}
+ ${localize("ui.panel.config.logs.scroll_down_button")}
+ ${streaming && this._boot === 0 && !this._error
+ ? html`
+
+ Live
+
`
+ : nothing}
- ${this.show === false
- ? html`
-
-
- ${this.hass.localize("ui.panel.config.logs.download_full_log")}
-
-
- ${this.hass.localize("ui.panel.config.logs.load_logs")}
-
- `
- : ""}
`;
}
- public connectedCallback() {
- super.connectedCallback();
+ protected willUpdate(changedProps: PropertyValues) {
+ super.willUpdate(changedProps);
+ if (!this.hasUpdated) {
+ this._downloadSupported = downloadFileSupported(this.hass);
+ this._streamSupported =
+ !__SUPERVISOR__ || atLeastVersion(this.hass.config.version, 2024, 11);
+
+ // just needs to be loaded once, because only the host endpoints provide boots information
+ this._loadBoots();
- if (this._streamSupported === undefined) {
- this._streamSupported = atLeastVersion(
- this.hass.config.version,
- 2024,
- 11
+ window.addEventListener(
+ "connection-status",
+ this._handleConnectionStatus
);
+
+ this.hass.loadFragmentTranslation("config");
+ }
+
+ if (changedProps.has("provider")) {
+ this._boot = 0;
+ this._loadLogs();
}
}
@@ -277,28 +368,11 @@ class ErrorLogCard extends LitElement {
this._scrolledToTopController.callback = this._handleTopScroll;
this._scrolledToTopController.observe(this._scrollTopMarkerElement!);
-
- window.addEventListener("connection-status", this._handleConnectionStatus);
-
- if (this.hass?.config.recovery_mode || this.show) {
- this.hass.loadFragmentTranslation("config");
- }
-
- // just needs to be loaded once, because only the host endpoints provide boots information
- this._loadBoots();
}
protected updated(changedProps) {
super.updated(changedProps);
- if (
- (changedProps.has("show") && this.show) ||
- (changedProps.has("provider") && this.show)
- ) {
- this._boot = 0;
- this._loadLogs();
- }
-
if (this._newLogsIndicator && this._scrolledToBottomController.value) {
this._newLogsIndicator = false;
}
@@ -331,8 +405,8 @@ class ErrorLogCard extends LitElement {
);
}
- private async _downloadFullLog(): Promise {
- if (this._streamSupported) {
+ private async _downloadLogs(): Promise {
+ if (this._streamSupported && this.provider) {
showDownloadLogsDialog(this, {
header: this.header,
provider: this.provider,
@@ -354,10 +428,6 @@ class ErrorLogCard extends LitElement {
}
}
- private _showLogs(): void {
- this.show = true;
- }
-
private async _loadLogs(): Promise {
this._error = undefined;
this._loadingState = "loading";
@@ -369,15 +439,28 @@ class ErrorLogCard extends LitElement {
try {
if (this._logStreamAborter) {
this._logStreamAborter.abort();
+ this._logStreamAborter = undefined;
}
- this._logStreamAborter = new AbortController();
-
if (
this._streamSupported &&
isComponentLoaded(this.hass, "hassio") &&
this.provider
) {
+ this._logStreamAborter = new AbortController();
+
+ // check if there are any logs at all
+ const testResponse = await fetchHassioLogs(
+ this.hass,
+ this.provider,
+ `entries=:-1:`,
+ this._boot
+ );
+ const testLogs = await testResponse.text();
+ if (!testLogs.trim()) {
+ this._loadingState = "empty";
+ }
+
const response = await fetchHassioLogsFollow(
this.hass,
this.provider,
@@ -438,6 +521,17 @@ class ErrorLogCard extends LitElement {
} else {
this._newLogsIndicator = true;
}
+
+ if (!this._downloadSupported) {
+ const downloadUrl = getHassioLogDownloadLinesUrl(
+ this.provider,
+ this._numberOfLines,
+ this._boot
+ );
+ getSignedPath(this.hass, downloadUrl).then((signedUrl) => {
+ this._logsFileLink = signedUrl.path;
+ });
+ }
}
}
} else {
@@ -445,8 +539,7 @@ class ErrorLogCard extends LitElement {
this._streamSupported = false;
let logs = "";
if (isComponentLoaded(this.hass, "hassio") && this.provider) {
- const repsonse = await fetchHassioLogs(this.hass, this.provider);
- logs = await repsonse.text();
+ logs = await fetchHassioLogsLegacy(this.hass, this.provider);
} else {
logs = await fetchErrorLog(this.hass);
}
@@ -461,10 +554,13 @@ class ErrorLogCard extends LitElement {
if (err.name === "AbortError") {
return;
}
- this._error = this.hass.localize("ui.panel.config.logs.failed_get_logs", {
- provider: this.provider,
- error: extractApiErrorMessage(err),
- });
+ this._error = (this.localizeFunc || this.hass.localize)(
+ "ui.panel.config.logs.failed_get_logs",
+ {
+ provider: this.provider,
+ error: extractApiErrorMessage(err),
+ }
+ );
}
}
@@ -495,60 +591,62 @@ class ErrorLogCard extends LitElement {
if (ev.detail === "disconnected" && this._logStreamAborter) {
this._logStreamAborter.abort();
}
- if (ev.detail === "connected" && this.show) {
+ if (ev.detail === "connected") {
this._loadLogs();
}
};
private async _loadMoreLogs() {
if (
- this._firstCursor &&
- this._loadingPrevState !== "loading" &&
- this._loadingState === "loaded" &&
- this._logElement
+ !this._firstCursor ||
+ this._loadingPrevState === "loading" ||
+ this._loadingState !== "loaded" ||
+ !this._logElement ||
+ !this.provider
) {
- const scrolledToBottom = this._scrolledToBottomController.value;
- const scrollPositionFromBottom =
- this._logElement.scrollHeight - this._logElement.scrollTop;
- this._loadingPrevState = "loading";
- const response = await fetchHassioLogs(
- this.hass,
- this.provider,
- `entries=${this._firstCursor}:-100:100`,
- this._boot
- );
+ return;
+ }
+ const scrolledToBottom = this._scrolledToBottomController.value;
+ const scrollPositionFromBottom =
+ this._logElement.scrollHeight - this._logElement.scrollTop;
+ this._loadingPrevState = "loading";
+ const response = await fetchHassioLogs(
+ this.hass,
+ this.provider,
+ `entries=${this._firstCursor}:-100:100`,
+ this._boot
+ );
- if (response.headers.has("X-First-Cursor")) {
- if (this._firstCursor === response.headers.get("X-First-Cursor")!) {
- this._loadingPrevState = "end";
- return;
- }
- this._firstCursor = response.headers.get("X-First-Cursor")!;
+ if (response.headers.has("X-First-Cursor")) {
+ if (this._firstCursor === response.headers.get("X-First-Cursor")!) {
+ this._loadingPrevState = "end";
+ return;
}
+ this._firstCursor = response.headers.get("X-First-Cursor")!;
+ }
- const body = await response.text();
+ const body = await response.text();
- if (body) {
- const lines = body
- .split("\n")
- .filter((line) => line.trim() !== "")
- .reverse();
+ if (body) {
+ const lines = body
+ .split("\n")
+ .filter((line) => line.trim() !== "")
+ .reverse();
- this._ansiToHtmlElement?.parseLinesToColoredPre(lines, true);
- this._numberOfLines! += lines.length;
- this._loadingPrevState = "loaded";
- } else {
- this._loadingPrevState = "end";
- }
+ this._ansiToHtmlElement?.parseLinesToColoredPre(lines, true);
+ this._numberOfLines! += lines.length;
+ this._loadingPrevState = "loaded";
+ } else {
+ this._loadingPrevState = "end";
+ }
- if (scrolledToBottom) {
- this._scrollToBottom();
- } else if (this._loadingPrevState !== "end" && this._logElement) {
- window.requestAnimationFrame(() => {
- this._logElement!.scrollTop =
- this._logElement!.scrollHeight - scrollPositionFromBottom;
- });
- }
+ if (scrolledToBottom) {
+ this._scrollToBottom();
+ } else if (this._loadingPrevState !== "end" && this._logElement) {
+ window.requestAnimationFrame(() => {
+ this._logElement!.scrollTop =
+ this._logElement!.scrollHeight - scrollPositionFromBottom;
+ });
}
}
@@ -585,6 +683,26 @@ class ErrorLogCard extends LitElement {
}
}
+ private _toggleLineWrap() {
+ this._wrapLines = !this._wrapLines;
+ }
+
+ private _handleOverflowAction(ev: CustomEvent) {
+ let index = ev.detail.index;
+ if (this.provider === "core") {
+ index--;
+ }
+ switch (index) {
+ case -1:
+ // @ts-ignore
+ fireEvent(this, "switch-log-view");
+ break;
+ case 0:
+ this._showBootsSelect = !this._showBootsSelect;
+ break;
+ }
+ }
+
private _toggleBootsMenu() {
if (this._bootsMenu) {
this._bootsMenu.open = !this._bootsMenu.open;
@@ -597,6 +715,9 @@ class ErrorLogCard extends LitElement {
}
static styles: CSSResultGroup = css`
+ :host {
+ direction: var(--direction);
+ }
.error-log-intro {
text-align: center;
margin: 16px;
@@ -646,14 +767,14 @@ class ErrorLogCard extends LitElement {
position: relative;
font-family: var(--code-font-family, monospace);
clear: both;
- text-align: left;
+ text-align: start;
padding-top: 12px;
padding-bottom: 12px;
overflow-y: scroll;
min-height: var(--error-log-card-height, calc(100vh - 240px));
max-height: var(--error-log-card-height, calc(100vh - 240px));
-
border-top: 1px solid var(--divider-color);
+ direction: ltr;
}
@media all and (max-width: 870px) {
@@ -713,6 +834,36 @@ class ErrorLogCard extends LitElement {
--ha-assist-chip-container-shape: 10px;
--md-assist-chip-trailing-space: 8px;
}
+
+ @keyframes breathe {
+ from {
+ opacity: 0.8;
+ }
+ to {
+ opacity: 0;
+ }
+ }
+
+ .live-indicator {
+ position: absolute;
+ bottom: 0;
+ inset-inline-end: 16px;
+ border-top-right-radius: 8px;
+ border-top-left-radius: 8px;
+ background-color: var(--primary-color);
+ color: var(--text-primary-color);
+ padding: 4px 8px;
+ opacity: 0.8;
+ }
+ .live-indicator ha-svg-icon {
+ animation: breathe 1s cubic-bezier(0.5, 0, 1, 1) infinite alternate;
+ height: 14px;
+ width: 14px;
+ }
+
+ .download-link {
+ color: var(--text-color);
+ }
`;
}
diff --git a/src/panels/config/logs/ha-config-logs.ts b/src/panels/config/logs/ha-config-logs.ts
index 4b15706054c6..3d6ae423c72b 100644
--- a/src/panels/config/logs/ha-config-logs.ts
+++ b/src/panels/config/logs/ha-config-logs.ts
@@ -3,20 +3,20 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
+import { navigate } from "../../../common/navigate";
import { extractSearchParam } from "../../../common/url/search-params";
-import "../../../components/ha-button-menu";
import "../../../components/ha-button";
+import "../../../components/ha-button-menu";
import "../../../components/search-input";
import type { LogProvider } from "../../../data/error_log";
import { fetchHassioAddonsInfo } from "../../../data/hassio/addon";
+import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import "./error-log-card";
import "./system-log-card";
import type { SystemLogCard } from "./system-log-card";
-import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
-import { navigate } from "../../../common/navigate";
const logProviders: LogProvider[] = [
{
@@ -57,6 +57,8 @@ export class HaConfigLogs extends LitElement {
@state() private _filter = extractSearchParam("filter") || "";
+ @state() private _detail = false;
+
@query("system-log-card") private systemLog?: SystemLogCard;
@state() private _selectedLogProvider = "core";
@@ -141,7 +143,7 @@ export class HaConfigLogs extends LitElement {
: ""}
${search}
- ${this._selectedLogProvider === "core"
+ ${this._selectedLogProvider === "core" && !this._detail
? html`
p.key === this._selectedLogProvider
)!.name}
.filter=${this._filter}
+ @switch-log-view=${this._showDetail}
>
`
- : ""}
- p.key === this._selectedLogProvider
- )!.name}
- .filter=${this._filter}
- .provider=${this._selectedLogProvider}
- .show=${this._selectedLogProvider !== "core"}
- >
+ : html` p.key === this._selectedLogProvider
+ )!.name}
+ .filter=${this._filter}
+ .provider=${this._selectedLogProvider}
+ @switch-log-view=${this._showDetail}
+ allow-switch
+ >`}
`;
}
+ private _showDetail() {
+ this._detail = !this._detail;
+ }
+
private _selectProvider(ev) {
this._selectedLogProvider = (ev.currentTarget as any).provider;
this._filter = "";
diff --git a/src/panels/config/logs/system-log-card.ts b/src/panels/config/logs/system-log-card.ts
index 1e1a9d12c120..60ef7284d00a 100644
--- a/src/panels/config/logs/system-log-card.ts
+++ b/src/panels/config/logs/system-log-card.ts
@@ -1,16 +1,20 @@
-import { mdiRefresh } from "@mdi/js";
import "@material/mwc-list/mwc-list";
+import { mdiDotsVertical, mdiDownload, mdiRefresh, mdiText } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
+import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/buttons/ha-call-service-button";
import "../../../components/buttons/ha-progress-button";
+import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-circular-progress";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
+import { getSignedPath } from "../../../data/auth";
+import { getErrorLogDownloadUrl } from "../../../data/error_log";
import { domainToName } from "../../../data/integration";
import type { LoggedError } from "../../../data/system_log";
import {
@@ -19,6 +23,7 @@ import {
isCustomIntegrationError,
} from "../../../data/system_log";
import type { HomeAssistant } from "../../../types";
+import { fileDownload } from "../../../util/file_download";
import { showSystemLogDetailDialog } from "./show-dialog-system-log-detail";
import { formatSystemLogTime } from "./util";
@@ -104,11 +109,34 @@ export class SystemLogCard extends LitElement {
: html`
${this._items.length === 0
? html`
@@ -195,6 +223,19 @@ export class SystemLogCard extends LitElement {
}
}
+ private _handleOverflowAction() {
+ // @ts-ignore
+ fireEvent(this, "switch-log-view");
+ }
+
+ private async _downloadLogs() {
+ const timeString = new Date().toISOString().replace(/:/g, "-");
+ const downloadUrl = getErrorLogDownloadUrl;
+ const logFileName = `home-assistant_${timeString}.log`;
+ const signedUrl = await getSignedPath(this.hass, downloadUrl);
+ fileDownload(signedUrl.path, logFileName);
+ }
+
private _openLog(ev: Event): void {
const item = (ev.currentTarget as any).logItem;
showSystemLogDetailDialog(this, { item });
@@ -203,7 +244,7 @@ export class SystemLogCard extends LitElement {
static get styles(): CSSResultGroup {
return css`
ha-card {
- padding-top: 16px;
+ padding-top: 8px;
}
.header {
@@ -212,6 +253,11 @@ export class SystemLogCard extends LitElement {
padding: 0 16px;
}
+ .header-buttons {
+ display: flex;
+ align-items: center;
+ }
+
.card-header {
color: var(--ha-card-header-color, var(--primary-text-color));
font-family: var(--ha-card-header-font-family, inherit);
@@ -243,6 +289,10 @@ export class SystemLogCard extends LitElement {
color: var(--warning-color);
}
+ .card-content {
+ border-top: 1px solid var(--divider-color);
+ }
+
.card-actions,
.empty-content {
direction: var(--direction);
diff --git a/src/panels/config/script/ha-script-editor.ts b/src/panels/config/script/ha-script-editor.ts
index a3eaaea6a46d..88da99c7afff 100644
--- a/src/panels/config/script/ha-script-editor.ts
+++ b/src/panels/config/script/ha-script-editor.ts
@@ -79,6 +79,8 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
@state() private _errors?: string;
+ @state() private _yamlErrors?: string;
+
@state() private _entityId?: string;
@state() private _mode: "gui" | "yaml" = "gui";
@@ -602,12 +604,14 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
private _yamlChanged(ev: CustomEvent) {
ev.stopPropagation();
+ this._dirty = true;
if (!ev.detail.isValid) {
+ this._yamlErrors = ev.detail.errorMsg;
return;
}
+ this._yamlErrors = undefined;
this._config = ev.detail.value;
this._errors = undefined;
- this._dirty = true;
}
private async confirmUnsavedChanged(): Promise {
@@ -723,7 +727,21 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
history.back();
}
- private _switchUiMode() {
+ private async _switchUiMode() {
+ if (this._yamlErrors) {
+ const result = await showConfirmationDialog(this, {
+ text: html`${this.hass.localize(
+ "ui.panel.config.automation.editor.switch_ui_yaml_error"
+ )}
${this._yamlErrors}`,
+ confirmText: this.hass!.localize("ui.common.continue"),
+ destructive: true,
+ dismissText: this.hass!.localize("ui.common.cancel"),
+ });
+ if (!result) {
+ return;
+ }
+ }
+ this._yamlErrors = undefined;
this._mode = "gui";
}
@@ -763,6 +781,13 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
}
private async _saveScript(): Promise {
+ if (this._yamlErrors) {
+ showToast(this, {
+ message: this._yamlErrors,
+ });
+ return;
+ }
+
if (!this.scriptId) {
const saved = await this._promptScriptAlias();
if (!saved) {
diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts
index c167c48da19d..39ff903a0c53 100644
--- a/src/panels/history/ha-panel-history.ts
+++ b/src/panels/history/ha-panel-history.ts
@@ -50,12 +50,7 @@ import {
} from "../../data/history";
import type { Statistics } from "../../data/recorder";
import { fetchStatistics } from "../../data/recorder";
-import {
- expandAreaTarget,
- expandDeviceTarget,
- expandFloorTarget,
- expandLabelTarget,
-} from "../../data/selector";
+import { resolveEntityIDs } from "../../data/selector";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../resources/styles";
@@ -543,66 +538,8 @@ class HaPanelHistory extends LitElement {
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"]
- ): string[] => {
- if (!targetPickerValue) {
- return [];
- }
-
- const targetSelector = { target: {} };
- const targetEntities = new Set(ensureArray(targetPickerValue.entity_id));
- const targetDevices = new Set(ensureArray(targetPickerValue.device_id));
- const targetAreas = new Set(ensureArray(targetPickerValue.area_id));
- const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
- const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
-
- targetLabels.forEach((labelId) => {
- const expanded = expandLabelTarget(
- this.hass,
- labelId,
- areas,
- devices,
- entities,
- targetSelector
- );
- expanded.devices.forEach((id) => targetDevices.add(id));
- expanded.entities.forEach((id) => targetEntities.add(id));
- expanded.areas.forEach((id) => targetAreas.add(id));
- });
-
- targetFloors.forEach((floorId) => {
- const expanded = expandFloorTarget(
- this.hass,
- floorId,
- areas,
- targetSelector
- );
- expanded.areas.forEach((id) => targetAreas.add(id));
- });
-
- targetAreas.forEach((areaId) => {
- const expanded = expandAreaTarget(
- this.hass,
- areaId,
- devices,
- entities,
- targetSelector
- );
- expanded.devices.forEach((id) => targetDevices.add(id));
- expanded.entities.forEach((id) => targetEntities.add(id));
- });
-
- targetDevices.forEach((deviceId) => {
- const expanded = expandDeviceTarget(
- this.hass,
- deviceId,
- entities,
- targetSelector
- );
- expanded.entities.forEach((id) => targetEntities.add(id));
- });
-
- return Array.from(targetEntities);
- }
+ ): string[] =>
+ resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
);
private _dateRangeChanged(ev) {
diff --git a/src/panels/logbook/ha-panel-logbook.ts b/src/panels/logbook/ha-panel-logbook.ts
index 54c9f9b02294..a69adc24a6a2 100644
--- a/src/panels/logbook/ha-panel-logbook.ts
+++ b/src/panels/logbook/ha-panel-logbook.ts
@@ -2,6 +2,8 @@ import { mdiRefresh } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
+import type { HassServiceTarget } from "home-assistant-js-websocket";
+import memoizeOne from "memoize-one";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
@@ -15,10 +17,14 @@ import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import "../../components/ha-top-app-bar-fixed";
+import "../../components/ha-target-picker";
import { filterLogbookCompatibleEntities } from "../../data/logbook";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "./ha-logbook";
+import { storage } from "../../common/decorators/storage";
+import { ensureArray } from "../../common/array/ensure-array";
+import { resolveEntityIDs } from "../../data/selector";
@customElement("ha-panel-logbook")
export class HaPanelLogbook extends LitElement {
@@ -33,6 +39,13 @@ export class HaPanelLogbook extends LitElement {
@state()
private _showBack?: boolean;
+ @storage({
+ key: "logbookPickedValue",
+ state: true,
+ subscribe: false,
+ })
+ private _targetPickerValue: HassServiceTarget = {};
+
public constructor() {
super();
@@ -82,21 +95,19 @@ export class HaPanelLogbook extends LitElement {
@change=${this._dateRangeChanged}
>
-
+ .value=${this._targetPickerValue}
+ addOnTop
+ @value-changed=${this._targetsChanged}
+ >
@@ -140,32 +151,62 @@ export class HaPanelLogbook extends LitElement {
this._applyURLParams();
};
+ private _getEntityIds(): string[] | undefined {
+ const entities = this.__getEntityIds(
+ this._targetPickerValue,
+ this.hass.entities,
+ this.hass.devices,
+ this.hass.areas
+ );
+ if (entities.length === 0) {
+ return undefined;
+ }
+ return entities;
+ }
+
+ private __getEntityIds = memoizeOne(
+ (
+ targetPickerValue: HassServiceTarget,
+ entities: HomeAssistant["entities"],
+ devices: HomeAssistant["devices"],
+ areas: HomeAssistant["areas"]
+ ): string[] =>
+ resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
+ );
+
private _applyURLParams() {
- const searchParams = new URLSearchParams(location.search);
-
- if (searchParams.has("entity_id")) {
- const entityIdsRaw = searchParams.get("entity_id");
-
- if (!entityIdsRaw) {
- this._entityIds = undefined;
- } else {
- const entityIds = entityIdsRaw.split(",").sort();
-
- // Check if different
- if (
- !this._entityIds ||
- entityIds.length !== this._entityIds.length ||
- !this._entityIds.every((val, idx) => val === entityIds[idx])
- ) {
- this._entityIds = entityIds;
- }
- }
- } else {
- this._entityIds = undefined;
+ const searchParams = extractSearchParamsObject();
+ const entityIds = searchParams.entity_id;
+ const deviceIds = searchParams.device_id;
+ const areaIds = searchParams.area_id;
+ const floorIds = searchParams.floor_id;
+ const labelsIds = searchParams.label_id;
+ if (entityIds || deviceIds || areaIds || floorIds || labelsIds) {
+ this._targetPickerValue = {};
+ }
+ if (entityIds) {
+ const splitIds = entityIds.split(",");
+ this._targetPickerValue!.entity_id = splitIds;
+ }
+ if (deviceIds) {
+ const splitIds = deviceIds.split(",");
+ this._targetPickerValue!.device_id = splitIds;
+ }
+ if (areaIds) {
+ const splitIds = areaIds.split(",");
+ this._targetPickerValue!.area_id = splitIds;
+ }
+ if (floorIds) {
+ const splitIds = floorIds.split(",");
+ this._targetPickerValue!.floor_id = splitIds;
+ }
+ if (labelsIds) {
+ const splitIds = labelsIds.split(",");
+ this._targetPickerValue!.label_id = splitIds;
}
- const startDateStr = searchParams.get("start_date");
- const endDateStr = searchParams.get("end_date");
+ const startDateStr = searchParams.start_date;
+ const endDateStr = searchParams.end_date;
if (startDateStr || endDateStr) {
const startDate = startDateStr
@@ -195,27 +236,48 @@ export class HaPanelLogbook extends LitElement {
endDate.setDate(endDate.getDate() + 1);
endDate.setMilliseconds(endDate.getMilliseconds() - 1);
}
- this._updatePath({
- start_date: startDate.toISOString(),
- end_date: endDate.toISOString(),
- });
+ this._time = {
+ range: [startDate, endDate],
+ };
+ this._updatePath();
}
- private _entityPicked(ev) {
- this._updatePath({
- entity_id: ev.target.value || undefined,
- });
+ private _targetsChanged(ev) {
+ this._targetPickerValue = ev.detail.value || {};
+ this._updatePath();
}
- private _updatePath(update: Record
) {
- const params = extractSearchParamsObject();
- for (const [key, value] of Object.entries(update)) {
- if (value === undefined) {
- delete params[key];
- } else {
- params[key] = value;
- }
+ private _updatePath() {
+ const params: Record = {};
+
+ if (this._targetPickerValue.entity_id) {
+ params.entity_id = ensureArray(this._targetPickerValue.entity_id).join(
+ ","
+ );
+ }
+ if (this._targetPickerValue.label_id) {
+ params.label_id = ensureArray(this._targetPickerValue.label_id).join(",");
+ }
+ if (this._targetPickerValue.floor_id) {
+ params.floor_id = ensureArray(this._targetPickerValue.floor_id).join(",");
+ }
+ if (this._targetPickerValue.area_id) {
+ params.area_id = ensureArray(this._targetPickerValue.area_id).join(",");
}
+ if (this._targetPickerValue.device_id) {
+ params.device_id = ensureArray(this._targetPickerValue.device_id).join(
+ ","
+ );
+ }
+
+ if (this._time.range[0]) {
+ params.start_date = this._time.range[0].toISOString();
+ }
+
+ if (this._time.range[1]) {
+ params.end_date = this._time.range[1].toISOString();
+ }
+
navigate(`/logbook?${createSearchParam(params)}`, { replace: true });
}
diff --git a/src/panels/lovelace/cards/energy/hui-energy-date-selection-card.ts b/src/panels/lovelace/cards/energy/hui-energy-date-selection-card.ts
index 8db4e6b44a6b..5e021d53dad3 100644
--- a/src/panels/lovelace/cards/energy/hui-energy-date-selection-card.ts
+++ b/src/panels/lovelace/cards/energy/hui-energy-date-selection-card.ts
@@ -1,11 +1,11 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
+import "../../../../components/ha-card";
import type { HomeAssistant } from "../../../../types";
import { hasConfigChanged } from "../../common/has-changed";
import "../../components/hui-energy-period-selector";
-import "../../../../components/ha-card";
-import type { LovelaceCard, LovelaceLayoutOptions } from "../../types";
+import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import type { EnergyCardBaseConfig } from "../types";
@customElement("hui-energy-date-selection-card")
@@ -21,10 +21,10 @@ export class HuiEnergyDateSelectionCard
return 1;
}
- public getLayoutOptions(): LovelaceLayoutOptions {
+ public getGridOptions(): LovelaceGridOptions {
return {
- grid_rows: 1,
- grid_columns: 4,
+ rows: 1,
+ columns: 12,
};
}
diff --git a/src/panels/lovelace/cards/hui-area-card.ts b/src/panels/lovelace/cards/hui-area-card.ts
index 1e2b335ade56..e8c0eb8cea78 100644
--- a/src/panels/lovelace/cards/hui-area-card.ts
+++ b/src/panels/lovelace/cards/hui-area-card.ts
@@ -45,7 +45,7 @@ import "../components/hui-warning";
import type {
LovelaceCard,
LovelaceCardEditor,
- LovelaceLayoutOptions,
+ LovelaceGridOptions,
} from "../types";
import type { AreaCardConfig } from "./types";
@@ -534,10 +534,11 @@ export class HuiAreaCard
forwardHaptic("light");
}
- getLayoutOptions(): LovelaceLayoutOptions {
+ getGridOptions(): LovelaceGridOptions {
return {
- grid_columns: 4,
- grid_rows: 3,
+ columns: 12,
+ rows: 3,
+ min_columns: 3,
};
}
diff --git a/src/panels/lovelace/cards/hui-button-card.ts b/src/panels/lovelace/cards/hui-button-card.ts
index 2bed7c6038e9..2fbb0adb2f9e 100644
--- a/src/panels/lovelace/cards/hui-button-card.ts
+++ b/src/panels/lovelace/cards/hui-button-card.ts
@@ -46,7 +46,7 @@ import { createEntityNotFoundWarning } from "../components/hui-warning";
import type {
LovelaceCard,
LovelaceCardEditor,
- LovelaceLayoutOptions,
+ LovelaceGridOptions,
} from "../types";
import type { ButtonCardConfig } from "./types";
@@ -134,20 +134,23 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
);
}
- public getLayoutOptions(): LovelaceLayoutOptions {
+ public getGridOptions(): LovelaceGridOptions {
if (
this._config?.show_icon &&
(this._config?.show_name || this._config?.show_state)
) {
return {
- grid_rows: 2,
- grid_columns: 2,
- grid_min_rows: 2,
+ rows: 2,
+ columns: 6,
+ min_columns: 2,
+ min_rows: 2,
};
}
return {
- grid_rows: 1,
- grid_columns: 1,
+ rows: 1,
+ columns: 3,
+ min_columns: 2,
+ min_rows: 1,
};
}
diff --git a/src/panels/lovelace/cards/hui-entity-card.ts b/src/panels/lovelace/cards/hui-entity-card.ts
index b2b849525540..6e5b30e2b44d 100644
--- a/src/panels/lovelace/cards/hui-entity-card.ts
+++ b/src/panels/lovelace/cards/hui-entity-card.ts
@@ -33,8 +33,8 @@ import { createEntityNotFoundWarning } from "../components/hui-warning";
import { createHeaderFooterElement } from "../create-element/create-header-footer-element";
import type {
LovelaceCard,
+ LovelaceGridOptions,
LovelaceHeaderFooter,
- LovelaceLayoutOptions,
} from "../types";
import type { HuiErrorCard } from "./hui-error-card";
import type { EntityCardConfig } from "./types";
@@ -249,12 +249,12 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
fireEvent(this, "hass-more-info", { entityId: this._config!.entity });
}
- public getLayoutOptions(): LovelaceLayoutOptions {
+ public getGridOptions(): LovelaceGridOptions {
return {
- grid_columns: 2,
- grid_rows: 2,
- grid_min_columns: 2,
- grid_min_rows: 2,
+ columns: 6,
+ rows: 2,
+ min_columns: 6,
+ min_rows: 2,
};
}
diff --git a/src/panels/lovelace/cards/hui-heading-card.ts b/src/panels/lovelace/cards/hui-heading-card.ts
index 6d57a1ae064c..307fc6bfdbe2 100644
--- a/src/panels/lovelace/cards/hui-heading-card.ts
+++ b/src/panels/lovelace/cards/hui-heading-card.ts
@@ -16,7 +16,7 @@ import "../heading-badges/hui-heading-badge";
import type {
LovelaceCard,
LovelaceCardEditor,
- LovelaceLayoutOptions,
+ LovelaceGridOptions,
} from "../types";
import type { HeadingCardConfig } from "./types";
@@ -65,10 +65,11 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
return 1;
}
- public getLayoutOptions(): LovelaceLayoutOptions {
+ public getGridOptions(): LovelaceGridOptions {
return {
- grid_columns: "full",
- grid_rows: this._config?.heading_style === "subtitle" ? "auto" : 1,
+ columns: "full",
+ rows: this._config?.heading_style === "subtitle" ? "auto" : 1,
+ min_columns: 3,
};
}
diff --git a/src/panels/lovelace/cards/hui-humidifier-card.ts b/src/panels/lovelace/cards/hui-humidifier-card.ts
index c342934afc6e..15bee2a43d8f 100644
--- a/src/panels/lovelace/cards/hui-humidifier-card.ts
+++ b/src/panels/lovelace/cards/hui-humidifier-card.ts
@@ -19,7 +19,7 @@ import { createEntityNotFoundWarning } from "../components/hui-warning";
import type {
LovelaceCard,
LovelaceCardEditor,
- LovelaceLayoutOptions,
+ LovelaceGridOptions,
} from "../types";
import type { HumidifierCardConfig } from "./types";
@@ -171,21 +171,21 @@ export class HuiHumidifierCard extends LitElement implements LovelaceCard {
`;
}
- public getLayoutOptions(): LovelaceLayoutOptions {
- const grid_columns = 4;
- let grid_rows = 5;
- let grid_min_rows = 2;
- const grid_min_columns = 2;
+ public getGridOptions(): LovelaceGridOptions {
+ const columns = 12;
+ let rows = 5;
+ let min_rows = 2;
+ const min_columns = 6;
if (this._config?.features?.length) {
const featureHeight = Math.ceil((this._config.features.length * 2) / 3);
- grid_rows += featureHeight;
- grid_min_rows += featureHeight;
+ rows += featureHeight;
+ min_rows += featureHeight;
}
return {
- grid_columns,
- grid_rows,
- grid_min_rows,
- grid_min_columns,
+ columns,
+ rows,
+ min_columns,
+ min_rows,
};
}
diff --git a/src/panels/lovelace/cards/hui-iframe-card.ts b/src/panels/lovelace/cards/hui-iframe-card.ts
index aa078608cebc..72188af3ba7d 100644
--- a/src/panels/lovelace/cards/hui-iframe-card.ts
+++ b/src/panels/lovelace/cards/hui-iframe-card.ts
@@ -11,7 +11,7 @@ import { IFRAME_SANDBOX } from "../../../util/iframe";
import type {
LovelaceCard,
LovelaceCardEditor,
- LovelaceLayoutOptions,
+ LovelaceGridOptions,
} from "../types";
import type { IframeCardConfig } from "./types";
@@ -113,11 +113,12 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
`;
}
- public getLayoutOptions(): LovelaceLayoutOptions {
+ public getGridOptions(): LovelaceGridOptions {
return {
- grid_columns: "full",
- grid_rows: 4,
- grid_min_rows: 2,
+ columns: "full",
+ rows: 4,
+ min_columns: 3,
+ min_rows: 2,
};
}
diff --git a/src/panels/lovelace/cards/hui-map-card.ts b/src/panels/lovelace/cards/hui-map-card.ts
index 9931303f93f6..f1234225722c 100644
--- a/src/panels/lovelace/cards/hui-map-card.ts
+++ b/src/panels/lovelace/cards/hui-map-card.ts
@@ -11,8 +11,8 @@ import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { deepEqual } from "../../../common/util/deep-equal";
import parseAspectRatio from "../../../common/util/parse-aspect-ratio";
-import "../../../components/ha-card";
import "../../../components/ha-alert";
+import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/map/ha-map";
import type {
@@ -23,15 +23,15 @@ import type {
} from "../../../components/map/ha-map";
import type { HistoryStates } from "../../../data/history";
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
+import type { HomeAssistant } from "../../../types";
+import { findEntities } from "../common/find-entities";
import {
hasConfigChanged,
hasConfigOrEntitiesChanged,
} from "../common/has-changed";
-import type { HomeAssistant } from "../../../types";
-import { findEntities } from "../common/find-entities";
import { processConfigEntities } from "../common/process-config-entities";
import type { EntityConfig } from "../entity-rows/types";
-import type { LovelaceCard, LovelaceLayoutOptions } from "../types";
+import type { LovelaceCard, LovelaceGridOptions } from "../types";
import type { MapCardConfig } from "./types";
export const DEFAULT_HOURS_TO_SHOW = 0;
@@ -431,12 +431,12 @@ class HuiMapCard extends LitElement implements LovelaceCard {
}
);
- public getLayoutOptions(): LovelaceLayoutOptions {
+ public getGridOptions(): LovelaceGridOptions {
return {
- grid_columns: "full",
- grid_rows: 4,
- grid_min_columns: 2,
- grid_min_rows: 2,
+ columns: "full",
+ rows: 4,
+ min_columns: 6,
+ min_rows: 2,
};
}
diff --git a/src/panels/lovelace/cards/hui-recovery-mode-card.ts b/src/panels/lovelace/cards/hui-recovery-mode-card.ts
index 10102b6dd728..37d979dc19e6 100644
--- a/src/panels/lovelace/cards/hui-recovery-mode-card.ts
+++ b/src/panels/lovelace/cards/hui-recovery-mode-card.ts
@@ -31,7 +31,7 @@ export class HuiRecoveryModeCard extends LitElement implements LovelaceCard {
"ui.panel.lovelace.cards.recovery-mode.description"
)}
-
+
`;
}
diff --git a/src/panels/lovelace/cards/hui-sensor-card.ts b/src/panels/lovelace/cards/hui-sensor-card.ts
index 0bc454e3c533..db964f9e0d6b 100644
--- a/src/panels/lovelace/cards/hui-sensor-card.ts
+++ b/src/panels/lovelace/cards/hui-sensor-card.ts
@@ -6,7 +6,7 @@ import { computeDomain } from "../../../common/entity/compute_domain";
import type { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities";
import type { GraphHeaderFooterConfig } from "../header-footer/types";
-import type { LovelaceCardEditor, LovelaceLayoutOptions } from "../types";
+import type { LovelaceCardEditor, LovelaceGridOptions } from "../types";
import { HuiEntityCard } from "./hui-entity-card";
import type { EntityCardConfig, SensorCardConfig } from "./types";
@@ -73,12 +73,12 @@ class HuiSensorCard extends HuiEntityCard {
super.setConfig(entityCardConfig);
}
- public getLayoutOptions(): LovelaceLayoutOptions {
+ public getGridOptions(): LovelaceGridOptions {
return {
- grid_columns: 2,
- grid_rows: 2,
- grid_min_columns: 2,
- grid_min_rows: 2,
+ columns: 6,
+ rows: 2,
+ min_columns: 6,
+ min_rows: 2,
};
}
diff --git a/src/panels/lovelace/cards/hui-statistic-card.ts b/src/panels/lovelace/cards/hui-statistic-card.ts
index e0ee0c079e17..7fafcf8a7db3 100644
--- a/src/panels/lovelace/cards/hui-statistic-card.ts
+++ b/src/panels/lovelace/cards/hui-statistic-card.ts
@@ -26,7 +26,7 @@ import type {
LovelaceCard,
LovelaceCardEditor,
LovelaceHeaderFooter,
- LovelaceLayoutOptions,
+ LovelaceGridOptions,
} from "../types";
import type { HuiErrorCard } from "./hui-error-card";
import type { EntityCardConfig, StatisticCardConfig } from "./types";
@@ -249,12 +249,12 @@ export class HuiStatisticCard extends LitElement implements LovelaceCard {
fireEvent(this, "hass-more-info", { entityId: this._config!.entity });
}
- public getLayoutOptions(): LovelaceLayoutOptions {
+ public getGridOptions(): LovelaceGridOptions {
return {
- grid_columns: 2,
- grid_rows: 2,
- grid_min_columns: 2,
- grid_min_rows: 2,
+ columns: 6,
+ rows: 2,
+ min_columns: 6,
+ min_rows: 2,
};
}
diff --git a/src/panels/lovelace/cards/hui-thermostat-card.ts b/src/panels/lovelace/cards/hui-thermostat-card.ts
index f96264e75eea..f33d1dbdfe50 100644
--- a/src/panels/lovelace/cards/hui-thermostat-card.ts
+++ b/src/panels/lovelace/cards/hui-thermostat-card.ts
@@ -19,7 +19,7 @@ import { createEntityNotFoundWarning } from "../components/hui-warning";
import type {
LovelaceCard,
LovelaceCardEditor,
- LovelaceLayoutOptions,
+ LovelaceGridOptions,
} from "../types";
import type { ThermostatCardConfig } from "./types";
@@ -163,21 +163,21 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
`;
}
- public getLayoutOptions(): LovelaceLayoutOptions {
- const grid_columns = 4;
- let grid_rows = 5;
- let grid_min_rows = 2;
- const grid_min_columns = 2;
+ public getGridOptions(): LovelaceGridOptions {
+ const columns = 12;
+ let rows = 5;
+ let min_rows = 2;
+ const min_columns = 6;
if (this._config?.features?.length) {
const featureHeight = Math.ceil((this._config.features.length * 2) / 3);
- grid_rows += featureHeight;
- grid_min_rows += featureHeight;
+ rows += featureHeight;
+ min_rows += featureHeight;
}
return {
- grid_columns,
- grid_rows,
- grid_min_rows,
- grid_min_columns,
+ columns,
+ rows,
+ min_columns,
+ min_rows,
};
}
diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts
index 820926d4bb16..457363681fd6 100644
--- a/src/panels/lovelace/cards/hui-tile-card.ts
+++ b/src/panels/lovelace/cards/hui-tile-card.ts
@@ -34,7 +34,7 @@ import { hasAction } from "../common/has-action";
import type {
LovelaceCard,
LovelaceCardEditor,
- LovelaceLayoutOptions,
+ LovelaceGridOptions,
} from "../types";
import { renderTileBadge } from "./tile/badges/tile-badge";
import type { ThermostatCardConfig, TileCardConfig } from "./types";
@@ -109,22 +109,22 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
);
}
- public getLayoutOptions(): LovelaceLayoutOptions {
- const grid_columns = 2;
- let grid_min_columns = 2;
- let grid_rows = 1;
+ public getGridOptions(): LovelaceGridOptions {
+ const columns = 6;
+ let min_columns = 6;
+ let rows = 1;
if (this._config?.features?.length) {
- grid_rows += this._config.features.length;
+ rows += this._config.features.length;
}
if (this._config?.vertical) {
- grid_rows++;
- grid_min_columns = 1;
+ rows++;
+ min_columns = 3;
}
return {
- grid_columns,
- grid_rows,
- grid_min_rows: grid_rows,
- grid_min_columns,
+ columns,
+ rows,
+ min_columns,
+ min_rows: rows,
};
}
diff --git a/src/panels/lovelace/cards/hui-weather-forecast-card.ts b/src/panels/lovelace/cards/hui-weather-forecast-card.ts
index 5fc2ec050438..ca8fc571af5e 100644
--- a/src/panels/lovelace/cards/hui-weather-forecast-card.ts
+++ b/src/panels/lovelace/cards/hui-weather-forecast-card.ts
@@ -34,7 +34,7 @@ import { createEntityNotFoundWarning } from "../components/hui-warning";
import type {
LovelaceCard,
LovelaceCardEditor,
- LovelaceLayoutOptions,
+ LovelaceGridOptions,
} from "../types";
import type { WeatherForecastCardConfig } from "./types";
@@ -418,31 +418,31 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
return typeof item !== "undefined" && item !== null;
}
- public getLayoutOptions(): LovelaceLayoutOptions {
+ public getGridOptions(): LovelaceGridOptions {
if (
this._config?.show_current !== false &&
this._config?.show_forecast !== false
) {
return {
- grid_columns: 4,
- grid_min_columns: 2,
- grid_rows: 4,
- grid_min_rows: 4,
+ columns: 12,
+ rows: 4,
+ min_columns: 6,
+ min_rows: 4,
};
}
if (this._config?.show_forecast !== false) {
return {
- grid_columns: 4,
- grid_min_columns: 2,
- grid_rows: 3,
- grid_min_rows: 3,
+ columns: 12,
+ rows: 3,
+ min_columns: 6,
+ min_rows: 3,
};
}
return {
- grid_columns: 4,
- grid_min_columns: 2,
- grid_rows: 2,
- grid_min_rows: 2,
+ columns: 12,
+ rows: 2,
+ min_columns: 6,
+ min_rows: 2,
};
}
diff --git a/src/panels/lovelace/common/compute-card-grid-size.ts b/src/panels/lovelace/common/compute-card-grid-size.ts
index cc0be5ac765a..49bc4946cab7 100644
--- a/src/panels/lovelace/common/compute-card-grid-size.ts
+++ b/src/panels/lovelace/common/compute-card-grid-size.ts
@@ -3,16 +3,11 @@ import type { LovelaceGridOptions, LovelaceLayoutOptions } from "../types";
export const GRID_COLUMN_MULTIPLIER = 3;
-export const multiplyBy = (
+const multiplyBy = (
value: T,
multiplier: number
): T => (typeof value === "number" ? ((value * multiplier) as T) : value);
-export const divideBy = (
- value: T,
- divider: number
-): T => (typeof value === "number" ? (Math.ceil(value / divider) as T) : value);
-
export const migrateLayoutToGridOptions = (
options: LovelaceLayoutOptions
): LovelaceGridOptions => {
@@ -42,6 +37,9 @@ export type CardGridSize = {
columns: number | "full";
};
+export const isPreciseMode = (options: LovelaceGridOptions) =>
+ typeof options.columns === "number" && options.columns % 3 !== 0;
+
export const computeCardGridSize = (
options: LovelaceGridOptions
): CardGridSize => {
diff --git a/src/panels/lovelace/common/directives/action-handler-directive.ts b/src/panels/lovelace/common/directives/action-handler-directive.ts
index d39db84b6be5..ef931d895b9c 100644
--- a/src/panels/lovelace/common/directives/action-handler-directive.ts
+++ b/src/panels/lovelace/common/directives/action-handler-directive.ts
@@ -149,7 +149,10 @@ class ActionHandler extends HTMLElement implements ActionHandlerType {
element.actionHandler.end = (ev: Event) => {
// Don't respond when moved or scrolled while touch
- if (["touchend", "touchcancel"].includes(ev.type) && this.cancelled) {
+ if (
+ ev.type === "touchcancel" ||
+ (ev.type === "touchend" && this.cancelled)
+ ) {
return;
}
const target = ev.target as HTMLElement;
diff --git a/src/panels/lovelace/components/hui-card-edit-mode.ts b/src/panels/lovelace/components/hui-card-edit-mode.ts
index c599910cce71..9775e67e734a 100644
--- a/src/panels/lovelace/components/hui-card-edit-mode.ts
+++ b/src/panels/lovelace/components/hui-card-edit-mode.ts
@@ -233,7 +233,7 @@ export class HuiCardEditMode extends LitElement {
}
private _handleAction(ev) {
- switch (ev.target.action) {
+ switch (ev.currentTarget.action) {
case "edit":
this._editCard();
break;
diff --git a/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts b/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts
index 206436ce959d..e133be40c1d9 100644
--- a/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts
+++ b/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts
@@ -23,6 +23,8 @@ const A11Y_KEY_CODES = new Set([
"End",
]);
+type TooltipMode = "never" | "always" | "interaction";
+
@customElement("ha-grid-layout-slider")
export class HaGridLayoutSlider extends LitElement {
@property({ type: Boolean, reflect: true })
@@ -34,6 +36,9 @@ export class HaGridLayoutSlider extends LitElement {
@property({ attribute: "touch-action" })
public touchAction?: string;
+ @property({ attribute: "tooltip-mode" })
+ public tooltipMode: TooltipMode = "interaction";
+
@property({ type: Number })
public value?: number;
@@ -52,6 +57,9 @@ export class HaGridLayoutSlider extends LitElement {
@state()
public pressed = false;
+ @state()
+ public tooltipVisible = false;
+
private _mc?: HammerManager;
private get _range() {
@@ -135,11 +143,13 @@ export class HaGridLayoutSlider extends LitElement {
this._mc.on("panstart", () => {
if (this.disabled) return;
this.pressed = true;
+ this._showTooltip();
savedValue = this.value;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this.pressed = false;
+ this._hideTooltip();
this.value = savedValue;
});
this._mc.on("panmove", (e) => {
@@ -152,6 +162,7 @@ export class HaGridLayoutSlider extends LitElement {
this._mc.on("panend", (e) => {
if (this.disabled) return;
this.pressed = false;
+ this._hideTooltip();
const percentage = this._getPercentageFromEvent(e);
const value = this._percentageToValue(percentage);
this.value = this._steppedValue(this._boundedValue(value));
@@ -223,6 +234,23 @@ export class HaGridLayoutSlider extends LitElement {
fireEvent(this, "value-changed", { value: this.value });
}
+ private _tooltipTimeout?: number;
+
+ _showTooltip() {
+ if (this._tooltipTimeout != null) window.clearTimeout(this._tooltipTimeout);
+ this.tooltipVisible = true;
+ }
+
+ _hideTooltip(delay?: number) {
+ if (!delay) {
+ this.tooltipVisible = false;
+ return;
+ }
+ this._tooltipTimeout = window.setTimeout(() => {
+ this.tooltipVisible = false;
+ }, delay);
+ }
+
private _getPercentageFromEvent = (e: HammerInput) => {
if (this.vertical) {
const y = e.center.y;
@@ -236,6 +264,30 @@ export class HaGridLayoutSlider extends LitElement {
return Math.max(Math.min(1, (x - offset) / total), 0);
};
+ private _renderTooltip() {
+ if (this.tooltipMode === "never") return nothing;
+
+ const position = this.vertical ? "left" : "top";
+
+ const visible =
+ this.tooltipMode === "always" ||
+ (this.tooltipVisible && this.tooltipMode === "interaction");
+
+ const value = this._boundedValue(this._steppedValue(this.value ?? 0));
+
+ return html`
+
+ ${value}
+
+ `;
+ }
+
protected render(): TemplateResult {
return html`
+
+ ${Array(this._range / this.step)
+ .fill(0)
+ .map((_, i) => {
+ const percentage = i / (this._range / this.step);
+ const disabled =
+ this.min >= i * this.step || i * this.step > this.max;
+ if (disabled) {
+ return nothing;
+ }
+ return html`
+
+ `;
+ })}
${this.value !== undefined
? html`
`
: nothing}
+ ${this._renderTooltip()}
`;
@@ -269,7 +341,7 @@ export class HaGridLayoutSlider extends LitElement {
return css`
:host {
display: block;
- --grid-layout-slider: 48px;
+ --grid-layout-slider: 36px;
height: var(--grid-layout-slider);
width: 100%;
outline: none;
@@ -297,6 +369,7 @@ export class HaGridLayoutSlider extends LitElement {
}
.slider * {
pointer-events: none;
+ user-select: none;
}
.track {
position: absolute;
@@ -315,12 +388,11 @@ export class HaGridLayoutSlider extends LitElement {
position: absolute;
inset: 0;
background: var(--disabled-color);
- opacity: 0.2;
+ opacity: 0.4;
}
.active {
position: absolute;
- background: grey;
- opacity: 0.7;
+ background: var(--primary-color);
top: 0;
right: calc(var(--max) * 100%);
bottom: 0;
@@ -351,6 +423,27 @@ export class HaGridLayoutSlider extends LitElement {
height: 16px;
width: 100%;
}
+ .dot {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ opacity: 0.6;
+ margin: auto;
+ width: 4px;
+ height: 4px;
+ flex-shrink: 0;
+ transform: translate(-50%, 0);
+ background: var(--card-background-color);
+ left: calc(var(--value, 0%) * 100%);
+ border-radius: 2px;
+ }
+ :host([vertical]) .dot {
+ transform: translate(0, -50%);
+ left: 0;
+ right: 0;
+ bottom: inherit;
+ top: calc(var(--value, 0%) * 100%);
+ }
.handle::after {
position: absolute;
inset: 0;
@@ -358,7 +451,7 @@ export class HaGridLayoutSlider extends LitElement {
border-radius: 2px;
height: 100%;
margin: auto;
- background: grey;
+ background: var(--primary-color);
content: "";
}
:host([vertical]) .handle::after {
@@ -374,9 +467,88 @@ export class HaGridLayoutSlider extends LitElement {
:host(:disabled) .active {
background: var(--disabled-color);
}
+
+ .tooltip {
+ position: absolute;
+ background-color: var(--clear-background-color);
+ color: var(--primary-text-color);
+ font-size: var(--control-slider-tooltip-font-size);
+ border-radius: 0.8em;
+ padding: 0.2em 0.4em;
+ opacity: 0;
+ white-space: nowrap;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
+ transition:
+ opacity 180ms ease-in-out,
+ left 180ms ease-in-out,
+ bottom 180ms ease-in-out;
+ --handle-spacing: calc(2 * var(--handle-margin) + var(--handle-size));
+ --slider-tooltip-margin: 0px;
+ --slider-tooltip-range: 100%;
+ --slider-tooltip-offset: 0px;
+ --slider-tooltip-position: calc(
+ min(
+ max(
+ var(--value) * var(--slider-tooltip-range) +
+ var(--slider-tooltip-offset),
+ 0%
+ ),
+ 100%
+ )
+ );
+ }
+ .tooltip.start {
+ --slider-tooltip-offset: calc(-0.5 * (var(--handle-spacing)));
+ }
+ .tooltip.end {
+ --slider-tooltip-offset: calc(0.5 * (var(--handle-spacing)));
+ }
+ .tooltip.cursor {
+ --slider-tooltip-range: calc(100% - var(--handle-spacing));
+ --slider-tooltip-offset: calc(0.5 * (var(--handle-spacing)));
+ }
+ .tooltip.show-handle {
+ --slider-tooltip-range: calc(100% - var(--handle-spacing));
+ --slider-tooltip-offset: calc(0.5 * (var(--handle-spacing)));
+ }
+ .tooltip.visible {
+ opacity: 1;
+ }
+ .tooltip.top {
+ transform: translate3d(-50%, -100%, 0);
+ top: var(--slider-tooltip-margin);
+ left: 50%;
+ }
+ .tooltip.bottom {
+ transform: translate3d(-50%, 100%, 0);
+ bottom: var(--slider-tooltip-margin);
+ left: 50%;
+ }
+ .tooltip.left {
+ transform: translate3d(-100%, -50%, 0);
+ top: 50%;
+ left: var(--slider-tooltip-margin);
+ }
+ .tooltip.right {
+ transform: translate3d(100%, -50%, 0);
+ top: 50%;
+ right: var(--slider-tooltip-margin);
+ }
+ :host(:not([vertical])) .tooltip.top,
+ :host(:not([vertical])) .tooltip.bottom {
+ left: var(--slider-tooltip-position);
+ }
+ :host([vertical]) .tooltip.right,
+ :host([vertical]) .tooltip.left {
+ top: var(--slider-tooltip-position);
+ }
+
.pressed .handle {
transition: none;
}
+ .pressed .tooltip {
+ transition: opacity 180ms ease-in-out;
+ }
`;
}
}
diff --git a/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts b/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts
index 494dcd9edbe3..a79e6eeccd1c 100644
--- a/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts
+++ b/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts
@@ -26,16 +26,12 @@ import type { HuiCard } from "../../cards/hui-card";
import type { CardGridSize } from "../../common/compute-card-grid-size";
import {
computeCardGridSize,
- divideBy,
GRID_COLUMN_MULTIPLIER,
+ isPreciseMode,
migrateLayoutToGridOptions,
- multiplyBy,
} from "../../common/compute-card-grid-size";
import type { LovelaceGridOptions } from "../../types";
-const computePreciseMode = (columns?: number | string) =>
- typeof columns === "number" && columns % 3 !== 0;
-
@customElement("hui-card-layout-editor")
export class HuiCardLayoutEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -63,22 +59,6 @@ export class HuiCardLayoutEditor extends LitElement {
private _computeCardGridSize = memoizeOne(computeCardGridSize);
- private _simplifyOptions = (
- options: LovelaceGridOptions
- ): LovelaceGridOptions => ({
- ...options,
- columns: divideBy(options.columns, GRID_COLUMN_MULTIPLIER),
- max_columns: divideBy(options.max_columns, GRID_COLUMN_MULTIPLIER),
- min_columns: divideBy(options.min_columns, GRID_COLUMN_MULTIPLIER),
- });
-
- private _standardizeOptions = (options: LovelaceGridOptions) => ({
- ...options,
- columns: multiplyBy(options.columns, GRID_COLUMN_MULTIPLIER),
- max_columns: multiplyBy(options.max_columns, GRID_COLUMN_MULTIPLIER),
- min_columns: multiplyBy(options.min_columns, GRID_COLUMN_MULTIPLIER),
- });
-
private _isDefault = memoizeOne(
(options?: LovelaceGridOptions) =>
options?.columns === undefined && options?.rows === undefined
@@ -101,14 +81,11 @@ export class HuiCardLayoutEditor extends LitElement {
this._defaultGridOptions
);
- const gridOptions = this._preciseMode
- ? options
- : this._simplifyOptions(options);
+ const gridOptions = options;
const gridValue = this._computeCardGridSize(gridOptions);
const columnSpan = this.sectionConfig.column_span ?? 1;
- const gridTotalColumns =
- (12 * columnSpan) / (this._preciseMode ? 1 : GRID_COLUMN_MULTIPLIER);
+ const gridTotalColumns = 12 * columnSpan;
return html`