mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-18 06:11:50 +00:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85e7a7c7af | |||
| fa7341a473 | |||
| b3d935bde2 | |||
| 929fd51f47 | |||
| 36031c7365 | |||
| f99d95232a | |||
| 5b4c08ad04 | |||
| b0b2d84287 | |||
| 0a43c29fea | |||
| 61a8df1fa9 | |||
| ba3b335f47 | |||
| 5449f31162 | |||
| 1a992aa5f7 | |||
| 6303affe17 | |||
| fc0ac85223 | |||
| 2efc1a658f | |||
| 371642ef3f | |||
| aff9ec4345 | |||
| 80e37e7136 | |||
| 54a234debd | |||
| eeb0fb3e4d | |||
| d5dc40fa1f | |||
| df12232e93 | |||
| 1a2e63a5ba | |||
| 581ba23f4e | |||
| 5dda82a8ac | |||
| 5a296fadec | |||
| 5dc99a3dbd | |||
| 2f36c64a21 | |||
| 59cfc82e7a | |||
| edc36b28a6 | |||
| 7b58162f81 | |||
| 9e9f247c79 | |||
| 8139b60248 | |||
| 386372ad00 | |||
| 9151b200a1 | |||
| f449c6c1c1 | |||
| d1eb3fd162 | |||
| b5d61d4041 | |||
| 79780b111c | |||
| a592a5f222 | |||
| 2ac8bf9179 | |||
| fd21dd2fd4 | |||
| 370ccd95da | |||
| ed2161effd | |||
| 19d902afc6 | |||
| 672d235c3e | |||
| 5246ce3f72 | |||
| c83924efa7 | |||
| bdbcec4d90 | |||
| a074c80ec3 | |||
| 3795ad1253 | |||
| 7bb466a75b | |||
| bff2514eed | |||
| 602d41b31d | |||
| 85d10cf982 | |||
| a3ff3346db | |||
| 38a314ced4 | |||
| 2cf7452ed1 | |||
| ae97cc1c8d | |||
| 65bba30266 | |||
| 8ee3544a32 | |||
| fcddf8f548 | |||
| c7824d4059 | |||
| 8c4f5206b1 | |||
| cc2a7972fc | |||
| 33079bb12c | |||
| 34152e522e | |||
| a0dc331056 | |||
| 4a56c1404f | |||
| 7e7845853d | |||
| f8fe7a7d82 | |||
| 8b40b55324 | |||
| ab55d1fdde | |||
| 597099f153 | |||
| 40ba2ade58 | |||
| 901fa4cdda | |||
| edf007718a | |||
| 5abaeea1f9 | |||
| 1ce0a7eab2 | |||
| c0c02eb548 | |||
| 18d5b84a02 | |||
| ebc58f025a | |||
| cb2758d868 |
@@ -30,7 +30,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
|
||||
- name: Setup lint cache
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.cache/prettier
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
@@ -41,14 +41,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -62,4 +62,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
uses: home-assistant/actions/helpers/verify-version@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
const generateMeanStatistics = (
|
||||
start: Date,
|
||||
end: Date,
|
||||
// eslint-disable-next-line default-param-last
|
||||
period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
maxDiff: number
|
||||
): StatisticValue[] => {
|
||||
@@ -49,7 +48,6 @@ const generateMeanStatistics = (
|
||||
const generateSumStatistics = (
|
||||
start: Date,
|
||||
end: Date,
|
||||
// eslint-disable-next-line default-param-last
|
||||
period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
initValue: number,
|
||||
maxDiff: number
|
||||
@@ -86,7 +84,6 @@ const generateSumStatistics = (
|
||||
const generateCurvedStatistics = (
|
||||
start: Date,
|
||||
end: Date,
|
||||
// eslint-disable-next-line default-param-last
|
||||
_period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
initValue: number,
|
||||
maxDiff: number,
|
||||
|
||||
+53
-64
@@ -2,10 +2,7 @@
|
||||
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
import globals from "globals";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import js from "@eslint/js";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import tseslint from "typescript-eslint";
|
||||
import eslintConfigPrettier from "eslint-config-prettier";
|
||||
import { configs as litConfigs } from "eslint-plugin-lit";
|
||||
@@ -14,35 +11,8 @@ import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
|
||||
import html from "@html-eslint/eslint-plugin";
|
||||
import importX from "eslint-plugin-import-x";
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = path.dirname(_filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: _dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
// Load airbnb-base via FlatCompat for non-import rules only.
|
||||
// eslint-plugin-import is incompatible with ESLint 10 (uses removed APIs),
|
||||
// so we strip its plugin/rules/settings and use eslint-plugin-import-x instead.
|
||||
const airbnbConfigs = compat.extends("airbnb-base").map((config) => {
|
||||
const { plugins = {}, rules = {}, settings = {}, ...rest } = config;
|
||||
return {
|
||||
...rest,
|
||||
plugins: Object.fromEntries(
|
||||
Object.entries(plugins).filter(([key]) => key !== "import")
|
||||
),
|
||||
rules: Object.fromEntries(
|
||||
Object.entries(rules).filter(([key]) => !key.startsWith("import/"))
|
||||
),
|
||||
settings: Object.fromEntries(
|
||||
Object.entries(settings).filter(([key]) => !key.startsWith("import/"))
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
export default tseslint.config(
|
||||
...airbnbConfigs,
|
||||
js.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
litConfigs["flat/all"],
|
||||
tseslint.configs.recommended,
|
||||
@@ -86,35 +56,59 @@ export default tseslint.config(
|
||||
},
|
||||
|
||||
rules: {
|
||||
"class-methods-use-this": "off",
|
||||
"new-cap": "off",
|
||||
"prefer-template": "off",
|
||||
"object-shorthand": "off",
|
||||
"func-names": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
strict: "off",
|
||||
"no-plusplus": "off",
|
||||
"no-bitwise": "error",
|
||||
"comma-dangle": "off",
|
||||
"vars-on-top": "off",
|
||||
"no-continue": "off",
|
||||
"no-param-reassign": "off",
|
||||
"no-multi-assign": "off",
|
||||
"no-console": "error",
|
||||
radix: "off",
|
||||
"no-alert": "off",
|
||||
"no-nested-ternary": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"no-restricted-globals": [2, "event"],
|
||||
"prefer-promise-reject-errors": "off",
|
||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||
"object-curly-newline": "off",
|
||||
"default-case": "off",
|
||||
"wc/no-self-class": "off",
|
||||
"no-shadow": "off",
|
||||
"no-use-before-define": "off",
|
||||
"array-callback-return": ["error", { allowImplicit: true }],
|
||||
"block-scoped-var": "error",
|
||||
"consistent-return": "error",
|
||||
curly: ["error", "multi-line"],
|
||||
"default-case-last": "error",
|
||||
eqeqeq: ["error", "always", { null: "ignore" }],
|
||||
"guard-for-in": "error",
|
||||
"no-await-in-loop": "error",
|
||||
"no-caller": "error",
|
||||
"no-constructor-return": "error",
|
||||
"no-eval": "error",
|
||||
"no-extend-native": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-iterator": "error",
|
||||
"no-new-func": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-promise-executor-return": "error",
|
||||
"no-return-assign": ["error", "always"],
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-unreachable-loop": "error",
|
||||
|
||||
// import-x rules (migrated from eslint-plugin-import / airbnb-base)
|
||||
"no-else-return": ["error", { allowElseIf: false }],
|
||||
"no-lonely-if": "error",
|
||||
"no-unneeded-ternary": ["error", { defaultAssignment: false }],
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"one-var": ["error", "never"],
|
||||
"operator-assignment": ["error", "always"],
|
||||
"prefer-arrow-callback": "error",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-object-spread": "error",
|
||||
"prefer-regex-literals": ["error", { disallowRedundantWrapping: true }],
|
||||
"symbol-description": "error",
|
||||
yoda: "error",
|
||||
|
||||
// TODO: Enable once violations are fixed (43 instances as of 2026-04)
|
||||
// "no-useless-assignment": "error",
|
||||
"no-useless-assignment": "error",
|
||||
|
||||
// Project rules
|
||||
"no-bitwise": "error",
|
||||
"no-console": "error",
|
||||
"no-restricted-globals": [2, "event"],
|
||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||
"wc/no-self-class": "off",
|
||||
|
||||
// import-x rules
|
||||
"import-x/named": "off",
|
||||
"import-x/prefer-default-export": "off",
|
||||
"import-x/no-default-export": "off",
|
||||
@@ -146,13 +140,9 @@ export default tseslint.config(
|
||||
"import-x/no-relative-packages": "error",
|
||||
|
||||
// TypeScript rules
|
||||
"@typescript-eslint/camelcase": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-shadow": ["error"],
|
||||
|
||||
"@typescript-eslint/naming-convention": [
|
||||
@@ -216,7 +206,6 @@ export default tseslint.config(
|
||||
"lit-a11y/role-has-required-aria-attrs": "error",
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
"@typescript-eslint/no-import-type-side-effects": "error",
|
||||
camelcase: "off",
|
||||
"@typescript-eslint/no-dynamic-delete": "off",
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
|
||||
+79
-29
@@ -1,17 +1,22 @@
|
||||
import "@material/mwc-drawer";
|
||||
import "@material/mwc-top-app-bar-fixed";
|
||||
import { mdiMenu } from "@mdi/js";
|
||||
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
|
||||
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
|
||||
import "../../src/components/ha-button";
|
||||
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
|
||||
import "../../src/components/ha-icon-button";
|
||||
import "../../src/components/ha-svg-icon";
|
||||
import "../../src/managers/notification-manager";
|
||||
import { haStyle } from "../../src/resources/styles";
|
||||
import { PAGES, SIDEBAR } from "../build/import-pages";
|
||||
import "./components/page-description";
|
||||
|
||||
const RTL_STORAGE_KEY = "gallery-rtl";
|
||||
|
||||
const GITHUB_DEMO_URL =
|
||||
"https://github.com/home-assistant/frontend/blob/dev/gallery/src/pages/";
|
||||
|
||||
@@ -29,6 +34,8 @@ class HaGallery extends LitElement {
|
||||
document.location.hash.substring(1) ||
|
||||
`${SIDEBAR[0].category}/${SIDEBAR[0].pages![0]}`;
|
||||
|
||||
@state() private _rtl = localStorage.getItem(RTL_STORAGE_KEY) === "true";
|
||||
|
||||
@query("notification-manager")
|
||||
private _notifications!: HTMLElementTagNameMap["notification-manager"];
|
||||
|
||||
@@ -97,33 +104,43 @@ class HaGallery extends LitElement {
|
||||
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
|
||||
</div>
|
||||
<div class="page-footer">
|
||||
<div class="header">Help us to improve our documentation</div>
|
||||
<div class="secondary">
|
||||
Suggest an edit to this page, or provide/view feedback for this
|
||||
page.
|
||||
<div class="edit-docs">
|
||||
<div class="header">Help us to improve our documentation</div>
|
||||
<div class="secondary">
|
||||
Suggest an edit to this page, or provide/view feedback for this
|
||||
page.
|
||||
</div>
|
||||
<div>
|
||||
${PAGES[this._page].description ||
|
||||
Object.keys(PAGES[this._page].metadata).length > 0
|
||||
? html`
|
||||
<a
|
||||
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
|
||||
target="_blank"
|
||||
>
|
||||
Edit text
|
||||
</a>
|
||||
`
|
||||
: ""}
|
||||
${PAGES[this._page].demo
|
||||
? html`
|
||||
<a
|
||||
href=${`${GITHUB_DEMO_URL}${this._page}.ts`}
|
||||
target="_blank"
|
||||
>
|
||||
Edit demo
|
||||
</a>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
${PAGES[this._page].description ||
|
||||
Object.keys(PAGES[this._page].metadata).length > 0
|
||||
? html`
|
||||
<a
|
||||
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
|
||||
target="_blank"
|
||||
>
|
||||
Edit text
|
||||
</a>
|
||||
`
|
||||
: ""}
|
||||
${PAGES[this._page].demo
|
||||
? html`
|
||||
<a
|
||||
href=${`${GITHUB_DEMO_URL}${this._page}.ts`}
|
||||
target="_blank"
|
||||
>
|
||||
Edit demo
|
||||
</a>
|
||||
`
|
||||
: ""}
|
||||
<div class="rtl-toggle">
|
||||
<ha-icon-button
|
||||
@click=${this._toggleRtl}
|
||||
.label=${this._rtl ? "Switch to LTR" : "Switch to RTL"}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiSwapHorizontal}></ha-svg-icon>
|
||||
</ha-icon-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,6 +155,8 @@ class HaGallery extends LitElement {
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
|
||||
this._applyDirection();
|
||||
|
||||
this.addEventListener("show-notification", (ev) =>
|
||||
this._notifications.showDialog({ message: ev.detail.message })
|
||||
);
|
||||
@@ -164,6 +183,11 @@ class HaGallery extends LitElement {
|
||||
|
||||
updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (changedProps.has("_rtl")) {
|
||||
this._applyDirection();
|
||||
}
|
||||
|
||||
if (!changedProps.has("_page")) {
|
||||
return;
|
||||
}
|
||||
@@ -186,6 +210,15 @@ class HaGallery extends LitElement {
|
||||
this._drawer.open = !this._drawer.open;
|
||||
}
|
||||
|
||||
private _toggleRtl() {
|
||||
this._rtl = !this._rtl;
|
||||
localStorage.setItem(RTL_STORAGE_KEY, String(this._rtl));
|
||||
}
|
||||
|
||||
private _applyDirection() {
|
||||
setDirectionStyles(this._rtl ? "rtl" : "ltr", this);
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyle,
|
||||
css`
|
||||
@@ -238,11 +271,16 @@ class HaGallery extends LitElement {
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
display: flex;
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.edit-docs {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
margin: 16px;
|
||||
padding: 16px;
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.page-footer div {
|
||||
@@ -266,6 +304,18 @@ class HaGallery extends LitElement {
|
||||
margin: 0 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rtl-toggle {
|
||||
padding: var(--ha-space-4);
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
margin-top: 12px !important;
|
||||
}
|
||||
|
||||
.rtl-toggle ha-icon-button {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-control-switch";
|
||||
|
||||
@@ -50,59 +51,100 @@ export class DemoHaControlSwitch extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(switches, (sw) => {
|
||||
const { id, label, ...config } = sw;
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<label id=${id}>${label}</label>
|
||||
<pre>Config: ${JSON.stringify(config)}</pre>
|
||||
<ha-control-switch
|
||||
.checked=${this.checked}
|
||||
class=${ifDefined(config.class)}
|
||||
@change=${this.handleValueChanged}
|
||||
.pathOn=${mdiLightbulb}
|
||||
.pathOff=${mdiLightbulbOff}
|
||||
.label=${label}
|
||||
?disabled=${config.disabled}
|
||||
?reversed=${config.reversed}
|
||||
>
|
||||
</ha-control-switch>
|
||||
<div class="themes">
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-control-switch ${mode}">
|
||||
${repeat(switches, (sw) => {
|
||||
const { id, label, ...config } = sw;
|
||||
return html`
|
||||
<div class="card-content">
|
||||
<label id="${mode}-${id}">${label}</label>
|
||||
<pre>Config: ${JSON.stringify(config)}</pre>
|
||||
<ha-control-switch
|
||||
.checked=${this.checked}
|
||||
class=${ifDefined(config.class)}
|
||||
@change=${this.handleValueChanged}
|
||||
.pathOn=${mdiLightbulb}
|
||||
.pathOff=${mdiLightbulbOff}
|
||||
.label=${label}
|
||||
?disabled=${config.disabled}
|
||||
?reversed=${config.reversed}
|
||||
>
|
||||
</ha-control-switch>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<div class="card-content">
|
||||
<p class="title"><b>Vertical</b></p>
|
||||
<div class="vertical-switches">
|
||||
${repeat(switches, (sw) => {
|
||||
const { label, ...config } = sw;
|
||||
return html`
|
||||
<ha-control-switch
|
||||
.checked=${this.checked}
|
||||
vertical
|
||||
class=${ifDefined(config.class)}
|
||||
@change=${this.handleValueChanged}
|
||||
.label=${label}
|
||||
.pathOn=${mdiGarageOpen}
|
||||
.pathOff=${mdiGarage}
|
||||
?disabled=${config.disabled}
|
||||
?reversed=${config.reversed}
|
||||
>
|
||||
</ha-control-switch>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
})}
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p class="title"><b>Vertical</b></p>
|
||||
<div class="vertical-switches">
|
||||
${repeat(switches, (sw) => {
|
||||
const { id, label, ...config } = sw;
|
||||
return html`
|
||||
<ha-control-switch
|
||||
.checked=${this.checked}
|
||||
vertical
|
||||
class=${ifDefined(config.class)}
|
||||
@change=${this.handleValueChanged}
|
||||
.label=${label}
|
||||
.pathOn=${mdiGarageOpen}
|
||||
.pathOff=${mdiGarage}
|
||||
?disabled=${config.disabled}
|
||||
?reversed=${config.reversed}
|
||||
>
|
||||
</ha-control-switch>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector(".dark"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: true,
|
||||
theme: "default",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.themes {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
.dark,
|
||||
.light {
|
||||
display: block;
|
||||
background-color: var(--primary-background-color);
|
||||
padding: 16px;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
ha-card {
|
||||
max-width: 600px;
|
||||
margin: 24px auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
pre {
|
||||
margin-top: 0;
|
||||
|
||||
@@ -27,7 +27,6 @@ export class DemoHaInput extends LitElement {
|
||||
constructor() {
|
||||
super();
|
||||
// Provides internationalizationContext for ha-input-copy, ha-input-multi and ha-input-search
|
||||
// eslint-disable-next-line no-new
|
||||
new ContextProvider(this, {
|
||||
context: internationalizationContext,
|
||||
initialValue: {
|
||||
|
||||
+14
-17
@@ -33,20 +33,20 @@
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/search": "6.7.0",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.41.1",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.3.2",
|
||||
"@formatjs/intl-displaynames": "7.3.2",
|
||||
"@formatjs/intl-durationformat": "0.10.4",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.3",
|
||||
"@formatjs/intl-listformat": "8.3.2",
|
||||
"@formatjs/intl-locale": "5.3.2",
|
||||
"@formatjs/intl-numberformat": "9.3.2",
|
||||
"@formatjs/intl-pluralrules": "6.3.2",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.2",
|
||||
"@formatjs/intl-datetimeformat": "7.4.0",
|
||||
"@formatjs/intl-displaynames": "7.3.3",
|
||||
"@formatjs/intl-durationformat": "0.10.5",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.4",
|
||||
"@formatjs/intl-listformat": "8.3.3",
|
||||
"@formatjs/intl-locale": "5.3.3",
|
||||
"@formatjs/intl-numberformat": "9.3.3",
|
||||
"@formatjs/intl-pluralrules": "6.3.3",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.3",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
@@ -99,7 +99,7 @@
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "11.2.1",
|
||||
"intl-messageformat": "11.2.2",
|
||||
"js-yaml": "4.1.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -135,7 +135,6 @@
|
||||
"@babel/plugin-transform-runtime": "7.29.0",
|
||||
"@babel/preset-env": "7.29.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.1",
|
||||
"@eslint/eslintrc": "3.3.5",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.59.0",
|
||||
"@lokalise/node-api": "15.7.1",
|
||||
@@ -162,16 +161,14 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "4.1.4",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.2.1",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
"eslint-plugin-lit": "2.2.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
@@ -200,12 +197,12 @@
|
||||
"serve": "14.2.6",
|
||||
"sinon": "21.1.2",
|
||||
"tar": "7.5.13",
|
||||
"terser-webpack-plugin": "5.4.0",
|
||||
"terser-webpack-plugin": "5.5.0",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.59.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.4",
|
||||
"vitest": "4.1.5",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
|
||||
|
||||
@@ -44,9 +44,9 @@ export const normalizeLuminance = (color: string): string => {
|
||||
return midL;
|
||||
}
|
||||
if (testLuminance < targetLuminance) {
|
||||
return findLightness(midL, highL, iterations--);
|
||||
return findLightness(midL, highL, iterations - 1);
|
||||
}
|
||||
return findLightness(lowL, midL, iterations--);
|
||||
return findLightness(lowL, midL, iterations - 1);
|
||||
}
|
||||
|
||||
baseOklch.l = findLightness();
|
||||
|
||||
@@ -21,8 +21,9 @@ export const closestWithProperty = (
|
||||
own
|
||||
? Object.prototype.hasOwnProperty.call(element, property)
|
||||
: element && property in element
|
||||
)
|
||||
) {
|
||||
return element;
|
||||
}
|
||||
return closestWithProperty(element, property, own);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
import { computeRTL } from "../util/compute_rtl";
|
||||
import { computeAreaName } from "./compute_area_name";
|
||||
import { computeDeviceName } from "./compute_device_name";
|
||||
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
|
||||
@@ -117,3 +118,32 @@ export const computeEntityNameList = (
|
||||
|
||||
return names;
|
||||
};
|
||||
|
||||
export interface EntityPickerDisplay {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
}
|
||||
|
||||
export const computeEntityPickerDisplay = (
|
||||
hass: HomeAssistant,
|
||||
stateObj: HassEntity
|
||||
): EntityPickerDisplay => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(hass);
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const secondary =
|
||||
[areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
|
||||
|
||||
return { primary, secondary };
|
||||
};
|
||||
|
||||
@@ -258,6 +258,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
"infrared",
|
||||
"input_button",
|
||||
"notify",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
"stt",
|
||||
"tag",
|
||||
|
||||
@@ -54,6 +54,7 @@ export const FIXED_DOMAIN_STATES = {
|
||||
],
|
||||
person: ["home", "not_home"],
|
||||
plant: ["ok", "problem"],
|
||||
radio_frequency: [],
|
||||
remote: ["on", "off"],
|
||||
scene: [],
|
||||
schedule: ["on", "off"],
|
||||
|
||||
@@ -7,7 +7,14 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
const compareState = state !== undefined ? state : stateObj?.state;
|
||||
|
||||
if (
|
||||
["button", "event", "infrared", "input_button", "scene"].includes(domain)
|
||||
[
|
||||
"button",
|
||||
"event",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
].includes(domain)
|
||||
) {
|
||||
return compareState !== UNAVAILABLE;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ const SECS_PER_HOUR = SECS_PER_MIN * 60;
|
||||
// Adapted from https://github.com/formatjs/formatjs/blob/186cef62f980ec66252ee232f438a42d0b51b9f9/packages/intl-utils/src/diff.ts
|
||||
export function selectUnit(
|
||||
from: Date | number,
|
||||
// eslint-disable-next-line default-param-last
|
||||
to: Date | number = Date.now(),
|
||||
locale: FrontendLocaleData,
|
||||
thresholds: Partial<Thresholds> = {}
|
||||
|
||||
@@ -360,14 +360,12 @@ export class HaChartBase extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
let itemStyle: Record<string, any> = {};
|
||||
let name = "";
|
||||
let id = "";
|
||||
let value = "";
|
||||
const name = typeof item === "string" ? item : (item.name ?? "");
|
||||
if (typeof item === "string") {
|
||||
name = item;
|
||||
id = item;
|
||||
} else {
|
||||
name = item.name ?? "";
|
||||
id = item.id ?? name;
|
||||
value = item.value ?? "";
|
||||
itemStyle = item.itemStyle ?? {};
|
||||
|
||||
@@ -291,20 +291,26 @@ export class HaSankeyChart extends LitElement {
|
||||
}
|
||||
|
||||
private _findParentIndex(id: string, links: Link[], sections: Node[][]) {
|
||||
const parent = links.find((l) => l.target === id)?.source;
|
||||
if (!parent) {
|
||||
const parents = links.filter((l) => l.target === id).map((l) => l.source);
|
||||
if (parents.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
let offset = 0;
|
||||
for (let i = sections.length - 1; i >= 0; i--) {
|
||||
const section = sections[i];
|
||||
const index = section.findIndex((n) => n.id === parent);
|
||||
if (index !== -1) {
|
||||
return offset + index;
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (const parent of parents) {
|
||||
let offset = 0;
|
||||
for (let i = sections.length - 1; i >= 0; i--) {
|
||||
const section = sections[i];
|
||||
const index = section.findIndex((n) => n.id === parent);
|
||||
if (index !== -1) {
|
||||
sum += offset + index;
|
||||
count++;
|
||||
break;
|
||||
}
|
||||
offset += section.length;
|
||||
}
|
||||
offset += section.length;
|
||||
}
|
||||
return -1;
|
||||
return count > 0 ? sum / count : -1;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -22,6 +22,7 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
|
||||
|
||||
const safeParseFloat = (value) => {
|
||||
const parsed = parseFloat(value);
|
||||
@@ -128,8 +129,9 @@ export class StateHistoryChartLine extends LitElement {
|
||||
if (
|
||||
dataset.tooltip?.show === false ||
|
||||
this._hiddenStats.has(dataset.id as string)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const param = params.find(
|
||||
(p: Record<string, any>) => p.seriesIndex === index
|
||||
);
|
||||
@@ -310,12 +312,50 @@ export class StateHistoryChartLine extends LitElement {
|
||||
.filter((item) => !(item.dataset as LineSeriesOption).areaStyle)
|
||||
.map((item) => {
|
||||
const stateObj = this.hass.states[item.entityId];
|
||||
let value: string | undefined;
|
||||
|
||||
if (stateObj) {
|
||||
// For climate temperature datasets, show temperature values
|
||||
const datasetId = item.dataset.id as string;
|
||||
if (
|
||||
datasetId?.endsWith("-current_temperature") ||
|
||||
datasetId?.endsWith("-target_temperature") ||
|
||||
datasetId?.endsWith("-target_temperature_mode") ||
|
||||
datasetId?.endsWith("-target_temperature_mode_low")
|
||||
) {
|
||||
let attribute: string | undefined;
|
||||
if (datasetId.endsWith("-current_temperature")) {
|
||||
attribute = "current_temperature";
|
||||
} else if (
|
||||
datasetId.endsWith("-target_temperature_mode_low")
|
||||
) {
|
||||
attribute = "target_temp_low";
|
||||
} else if (datasetId.endsWith("-target_temperature_mode")) {
|
||||
attribute = "target_temp_high";
|
||||
} else {
|
||||
attribute = "temperature";
|
||||
}
|
||||
// Use the helper to format temperature with proper unit
|
||||
value = computeAttributeValueDisplay(
|
||||
this.hass.localize,
|
||||
stateObj,
|
||||
this.hass.locale,
|
||||
this.hass.config,
|
||||
this.hass.entities,
|
||||
attribute
|
||||
);
|
||||
}
|
||||
|
||||
// Default for non-temperature datasets / missing attribute
|
||||
if (value === undefined) {
|
||||
value = this.hass.formatEntityState(stateObj);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.dataset.id as string,
|
||||
name: item.dataset.name as string,
|
||||
value: stateObj
|
||||
? this.hass.formatEntityState(stateObj)
|
||||
: undefined,
|
||||
value: value,
|
||||
};
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -4,9 +4,8 @@ import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
|
||||
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
|
||||
import { isValidEntityId } from "../../common/entity/valid_entity_id";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import {
|
||||
entityComboBoxKeys,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
type EntityComboBoxItem,
|
||||
} from "../../data/entity/entity_picker";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { EntitySelectorExtraOption } from "../../data/selector";
|
||||
import {
|
||||
isHelperDomain,
|
||||
type HelperDomain,
|
||||
@@ -22,6 +22,7 @@ import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import "../ha-icon";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
@@ -111,12 +112,21 @@ export class HaEntityPicker extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
/**
|
||||
* Extra options shown alongside entities. The `id` is used as the value
|
||||
* when the option is selected (it does not need to be a valid entity id).
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public extraOptions?: EntitySelectorExtraOption[];
|
||||
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
|
||||
@property({ attribute: "add-button", type: Boolean })
|
||||
public addButton = false;
|
||||
|
||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
@@ -125,9 +135,54 @@ export class HaEntityPicker extends LitElement {
|
||||
this.hass.loadBackendTranslation("title");
|
||||
}
|
||||
|
||||
private _findExtraOption(value: string | undefined) {
|
||||
return value
|
||||
? this.extraOptions?.find((opt) => opt.id === value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _renderExtraOptionStart(extraOption: EntitySelectorExtraOption) {
|
||||
const stateObj = extraOption.entity_id
|
||||
? this.hass.states[extraOption.entity_id]
|
||||
: undefined;
|
||||
if (stateObj) {
|
||||
return html`
|
||||
<state-badge
|
||||
slot="start"
|
||||
.stateObj=${stateObj}
|
||||
.hass=${this.hass}
|
||||
></state-badge>
|
||||
`;
|
||||
}
|
||||
if (extraOption.icon_path) {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${extraOption.icon_path}
|
||||
style="margin: 0 4px"
|
||||
></ha-svg-icon>
|
||||
`;
|
||||
}
|
||||
if (extraOption.icon) {
|
||||
return html`<ha-icon slot="start" .icon=${extraOption.icon}></ha-icon>`;
|
||||
}
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value) => {
|
||||
const entityId = value || "";
|
||||
|
||||
const extraOption = this._findExtraOption(entityId);
|
||||
if (extraOption) {
|
||||
return html`
|
||||
${this._renderExtraOptionStart(extraOption)}
|
||||
<span slot="headline">${extraOption.primary}</span>
|
||||
${extraOption.secondary
|
||||
? html`<span slot="supporting-text">${extraOption.secondary}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
const stateObj = this.hass.states[entityId];
|
||||
|
||||
if (!stateObj) {
|
||||
@@ -141,22 +196,11 @@ export class HaEntityPicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
const { primary, secondary } = computeEntityPickerDisplay(
|
||||
this.hass,
|
||||
stateObj
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(this.hass);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
return html`
|
||||
<state-badge
|
||||
.hass=${this.hass}
|
||||
@@ -253,8 +297,8 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne(getEntities);
|
||||
|
||||
private _getItems = () =>
|
||||
this._getEntitiesMemoized(
|
||||
private _getItems = () => {
|
||||
const items = this._getEntitiesMemoized(
|
||||
this.hass,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
@@ -265,6 +309,19 @@ export class HaEntityPicker extends LitElement {
|
||||
this.excludeEntities,
|
||||
this.value
|
||||
);
|
||||
if (this.extraOptions?.length) {
|
||||
const resolvedExtras = this.extraOptions.map((opt) => ({
|
||||
...opt,
|
||||
stateObj: opt.entity_id ? this.hass.states[opt.entity_id] : undefined,
|
||||
}));
|
||||
return [...resolvedExtras, ...items];
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
private _shouldHideClearIcon() {
|
||||
return !!this._findExtraOption(this.value)?.hide_clear;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const placeholder =
|
||||
@@ -287,13 +344,14 @@ export class HaEntityPicker extends LitElement {
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.hideClearIcon=${this.hideClearIcon || this._shouldHideClearIcon()}
|
||||
.searchFn=${this._searchFn}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.searchKeys=${entityComboBoxKeys}
|
||||
use-top-label
|
||||
.addButtonLabel=${this.addButton
|
||||
? this.hass.localize("ui.components.entity.entity-picker.add")
|
||||
? (this.addButtonLabel ??
|
||||
this.hass.localize("ui.components.entity.entity-picker.add"))
|
||||
: undefined}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.components.entity.entity-picker.unknown"
|
||||
@@ -347,7 +405,7 @@ export class HaEntityPicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidEntityId(value)) {
|
||||
if (!isValidEntityId(value) && !this._findExtraOption(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,6 @@ export class HaEntityStatePicker extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
|
||||
|
||||
private _getItems = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
@@ -122,12 +120,13 @@ export class HaEntityStatePicker extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const noEntity = !ensureArray(this.entityId)?.length;
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.disabled=${this.disabled ||
|
||||
(!this.entityId && this.noEntity === false)}
|
||||
.disabled=${this.disabled || noEntity}
|
||||
.autofocus=${this.autofocus}
|
||||
.required=${this.required}
|
||||
.label=${this.label ??
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
} from "../../data/entity/entity";
|
||||
import { forwardHaptic } from "../../data/haptics";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-control-switch";
|
||||
import "../ha-formfield";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-switch";
|
||||
|
||||
const isOn = (stateObj?: HassEntity) =>
|
||||
stateObj !== undefined &&
|
||||
@@ -35,7 +35,7 @@ export class HaEntityToggle extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this.stateObj) {
|
||||
return html` <ha-switch disabled></ha-switch> `;
|
||||
return html`<ha-control-switch disabled></ha-control-switch> `;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -62,14 +62,14 @@ export class HaEntityToggle extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const switchTemplate = html`<ha-switch
|
||||
const switchTemplate = html`<ha-control-switch
|
||||
aria-label=${`Toggle ${computeStateName(this.stateObj)} ${
|
||||
this._isOn ? "off" : "on"
|
||||
}`}
|
||||
.checked=${this._isOn}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@change=${this._toggleChanged}
|
||||
></ha-switch>`;
|
||||
></ha-control-switch>`;
|
||||
|
||||
if (!this.label) {
|
||||
return switchTemplate;
|
||||
@@ -163,6 +163,10 @@ export class HaEntityToggle extends LitElement {
|
||||
white-space: nowrap;
|
||||
min-width: 38px;
|
||||
}
|
||||
ha-control-switch {
|
||||
--control-switch-thickness: 20px;
|
||||
--control-switch-off-color: var(--state-inactive-color);
|
||||
}
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
color: var(--ha-icon-button-inactive-color, var(--primary-text-color));
|
||||
@@ -171,9 +175,6 @@ export class HaEntityToggle extends LitElement {
|
||||
ha-icon-button.state-active {
|
||||
color: var(--ha-icon-button-active-color, var(--primary-color));
|
||||
}
|
||||
ha-switch {
|
||||
padding: 13px 5px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,6 @@ export class HaAnsiToHtml extends LitElement {
|
||||
lineDiv.appendChild(span);
|
||||
};
|
||||
|
||||
/* eslint-disable no-cond-assign */
|
||||
let match;
|
||||
|
||||
while ((match = re.exec(line)) !== null) {
|
||||
|
||||
@@ -26,7 +26,7 @@ class HaAttributeValue extends LitElement {
|
||||
try {
|
||||
// If invalid URL, exception will be raised
|
||||
const url = new URL(attributeValue);
|
||||
if (url.protocol === "http:" || url.protocol === "https:")
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
return html`
|
||||
<a
|
||||
target="_blank"
|
||||
@@ -36,6 +36,7 @@ class HaAttributeValue extends LitElement {
|
||||
${attributeValue}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
} catch {
|
||||
// Nothing to do here
|
||||
}
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { resolveTimeZone } from "../common/datetime/resolve-time-zone";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { configContext, internationalizationContext } from "../data/context";
|
||||
import {
|
||||
CLOCK_CARD_DATE_PARTS,
|
||||
formatClockCardDate,
|
||||
} from "../panels/lovelace/cards/clock/clock-date-format";
|
||||
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./chips/ha-assist-chip";
|
||||
import "./chips/ha-chip-set";
|
||||
import "./chips/ha-input-chip";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import "./ha-input-helper-text";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import "./ha-sortable";
|
||||
|
||||
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
|
||||
|
||||
type ClockDateSeparatorPart = Extract<
|
||||
ClockCardDatePart,
|
||||
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
|
||||
>;
|
||||
|
||||
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
|
||||
"day",
|
||||
"month",
|
||||
"year",
|
||||
"weekday",
|
||||
"separator",
|
||||
];
|
||||
|
||||
const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
|
||||
"separator-dash": "-",
|
||||
"separator-slash": "/",
|
||||
"separator-dot": ".",
|
||||
"separator-new-line": "",
|
||||
};
|
||||
|
||||
const getClockDatePartSection = (
|
||||
part: ClockCardDatePart
|
||||
): ClockDatePartSection => {
|
||||
if (part.startsWith("weekday-")) {
|
||||
return "weekday";
|
||||
}
|
||||
|
||||
if (part.startsWith("day-")) {
|
||||
return "day";
|
||||
}
|
||||
|
||||
if (part.startsWith("month-")) {
|
||||
return "month";
|
||||
}
|
||||
|
||||
if (part.startsWith("year-")) {
|
||||
return "year";
|
||||
}
|
||||
|
||||
return "separator";
|
||||
};
|
||||
|
||||
interface ClockDatePartSectionData {
|
||||
id: ClockDatePartSection;
|
||||
title: string;
|
||||
items: PickerComboBoxItem[];
|
||||
}
|
||||
|
||||
interface ClockDatePartValueItem {
|
||||
key: string;
|
||||
item: string;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("ha-clock-date-format-picker")
|
||||
export class HaClockDateFormatPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public value?: string[] | string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig!: ContextType<typeof configContext>;
|
||||
|
||||
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
|
||||
|
||||
private _editIndex?: number;
|
||||
|
||||
protected render() {
|
||||
const value = this._value;
|
||||
const valueItems = this._getValueItems(value);
|
||||
const sections = this._buildSections();
|
||||
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.value=${this._getPickerValue()}
|
||||
.sections=${this._getSectionHeaders(sections)}
|
||||
.getItems=${this._getItems(sections)}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
>
|
||||
<div slot="field" class="container">
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
valueItems,
|
||||
(entry: ClockDatePartValueItem) => entry.key,
|
||||
({ item, idx }) => this._renderValueChip(item, idx, sections)
|
||||
)}
|
||||
${this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this._i18n.localize("ui.common.add")}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
</ha-generic-picker>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _getValueItems = memoizeOne(
|
||||
(value: string[]): ClockDatePartValueItem[] => {
|
||||
const occurrences = new Map<string, number>();
|
||||
|
||||
return value.map((item, idx) => {
|
||||
const occurrence = occurrences.get(item) ?? 0;
|
||||
occurrences.set(item, occurrence + 1);
|
||||
|
||||
return {
|
||||
key: `${item}:${occurrence}`,
|
||||
item,
|
||||
idx,
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private _renderValueChip(
|
||||
item: string,
|
||||
idx: number,
|
||||
sections: ClockDatePartSectionData[]
|
||||
) {
|
||||
const label = this._getItemLabel(item, sections);
|
||||
const isValid = !!label;
|
||||
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._editItem}
|
||||
.label=${label ?? item}
|
||||
.selected=${!this.disabled}
|
||||
.disabled=${this.disabled}
|
||||
class=${!isValid ? "invalid" : ""}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _addItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._editIndex = undefined;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
|
||||
10
|
||||
);
|
||||
this._editIndex = idx;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private get _value() {
|
||||
return !this.value ? [] : ensureArray(this.value);
|
||||
}
|
||||
|
||||
private _toValue = memoizeOne((value: string[]): string[] | undefined =>
|
||||
value.length === 0 ? undefined : value
|
||||
);
|
||||
|
||||
private _buildSections(): ClockDatePartSectionData[] {
|
||||
const itemsBySection: Record<ClockDatePartSection, PickerComboBoxItem[]> = {
|
||||
weekday: [],
|
||||
day: [],
|
||||
month: [],
|
||||
year: [],
|
||||
separator: [],
|
||||
};
|
||||
|
||||
const previewDate = new Date();
|
||||
const previewTimeZone = resolveTimeZone(
|
||||
this._i18n.locale.time_zone,
|
||||
this._hassConfig.config.time_zone
|
||||
);
|
||||
|
||||
CLOCK_CARD_DATE_PARTS.forEach((part) => {
|
||||
const section = getClockDatePartSection(part);
|
||||
const label =
|
||||
this._i18n.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.parts.${part}`
|
||||
) ?? part;
|
||||
|
||||
const secondary =
|
||||
section === "separator"
|
||||
? CLOCK_DATE_SEPARATOR_VALUES[part as ClockDateSeparatorPart]
|
||||
: formatClockCardDate(
|
||||
previewDate,
|
||||
{ parts: [part] },
|
||||
this._i18n.locale.language,
|
||||
previewTimeZone
|
||||
);
|
||||
|
||||
itemsBySection[section].push({
|
||||
id: part,
|
||||
primary: label,
|
||||
secondary,
|
||||
sorting_label: label,
|
||||
});
|
||||
});
|
||||
|
||||
return CLOCK_DATE_PART_SECTION_ORDER.map((section) => ({
|
||||
id: section,
|
||||
title:
|
||||
this._i18n.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.sections.${section}`
|
||||
) ?? section,
|
||||
items: itemsBySection[section],
|
||||
})).filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
private _getSectionHeaders(
|
||||
sections: ClockDatePartSectionData[]
|
||||
): { id: string; label: string }[] {
|
||||
return sections.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.title,
|
||||
}));
|
||||
}
|
||||
|
||||
private _getItems = memoizeOne(
|
||||
(sections: ClockDatePartSectionData[]) =>
|
||||
(
|
||||
searchString?: string,
|
||||
section?: string
|
||||
): (PickerComboBoxItem | string)[] => {
|
||||
const normalizedSearch = searchString?.trim().toLowerCase();
|
||||
|
||||
const filteredSections = sections
|
||||
.map((sectionData) => {
|
||||
if (!normalizedSearch) {
|
||||
return sectionData;
|
||||
}
|
||||
|
||||
return {
|
||||
...sectionData,
|
||||
items: sectionData.items.filter(
|
||||
(item) =>
|
||||
item.primary.toLowerCase().includes(normalizedSearch) ||
|
||||
item.secondary?.toLowerCase().includes(normalizedSearch) ||
|
||||
item.id.toLowerCase().includes(normalizedSearch)
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((sectionData) => sectionData.items.length > 0);
|
||||
|
||||
if (section) {
|
||||
return (
|
||||
filteredSections.find((candidate) => candidate.id === section)
|
||||
?.items || []
|
||||
);
|
||||
}
|
||||
|
||||
const groupedItems: (PickerComboBoxItem | string)[] = [];
|
||||
|
||||
filteredSections.forEach((sectionData) => {
|
||||
groupedItems.push(sectionData.title, ...sectionData.items);
|
||||
});
|
||||
|
||||
return groupedItems;
|
||||
}
|
||||
);
|
||||
|
||||
private _getItemLabel(
|
||||
value: string,
|
||||
sections: ClockDatePartSectionData[]
|
||||
): string | undefined {
|
||||
for (const section of sections) {
|
||||
const item = section.items.find((candidate) => candidate.id === value);
|
||||
|
||||
if (item) {
|
||||
if (section.id === "separator") {
|
||||
if (value === "separator-new-line") {
|
||||
return item.primary;
|
||||
}
|
||||
|
||||
return item.secondary ?? item.primary;
|
||||
}
|
||||
|
||||
return `${item.secondary} [${item.primary} ${section.title}]`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getPickerValue(): string | undefined {
|
||||
if (this._editIndex != null) {
|
||||
return this._value[this._editIndex];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
|
||||
const value = this._value;
|
||||
const newValue = value.concat();
|
||||
const element = newValue.splice(oldIndex, 1)[0];
|
||||
newValue.splice(newIndex, 0, element);
|
||||
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
|
||||
10
|
||||
);
|
||||
|
||||
if (Number.isNaN(idx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = [...this._value];
|
||||
value.splice(idx, 1);
|
||||
|
||||
if (this._editIndex !== undefined) {
|
||||
if (this._editIndex === idx) {
|
||||
this._editIndex = undefined;
|
||||
} else if (this._editIndex > idx) {
|
||||
this._editIndex -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (this.disabled || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = [...this._value];
|
||||
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = value;
|
||||
this._editIndex = undefined;
|
||||
} else {
|
||||
newValue.push(value);
|
||||
}
|
||||
|
||||
this._setValue(newValue);
|
||||
|
||||
if (this._picker) {
|
||||
this._picker.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _setValue(value: string[]) {
|
||||
const newValue = this._toValue(value);
|
||||
this.value = newValue;
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
background-color: var(--mdc-text-field-fill-color, whitesmoke);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-end-end-radius: var(--ha-border-radius-square);
|
||||
border-end-start-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
|
||||
.container:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background-color: var(
|
||||
--mdc-text-field-idle-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
transition:
|
||||
height 180ms ease-in-out,
|
||||
background-color 180ms ease-in-out;
|
||||
}
|
||||
|
||||
:host([disabled]) .container:after {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
|
||||
.container:focus-within:after {
|
||||
height: 2px;
|
||||
background-color: var(--mdc-theme-primary);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
}
|
||||
|
||||
.add {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.sortable-fallback {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
ha-input-helper-text {
|
||||
display: block;
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-clock-date-format-picker": HaClockDateFormatPicker;
|
||||
}
|
||||
}
|
||||
@@ -370,11 +370,12 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
|
||||
private _fullscreenLabel(): string {
|
||||
if (this._isFullscreen)
|
||||
if (this._isFullscreen) {
|
||||
return (
|
||||
this.hass?.localize("ui.components.yaml-editor.exit_fullscreen") ||
|
||||
"Exit fullscreen"
|
||||
);
|
||||
}
|
||||
return (
|
||||
this.hass?.localize("ui.components.yaml-editor.enter_fullscreen") ||
|
||||
"Enter fullscreen"
|
||||
@@ -914,7 +915,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
// In both cases the parent is a MemberExpression.
|
||||
const memberNode = node.parent;
|
||||
// "from" for the completion result (start of what the user is currently typing)
|
||||
let completionFrom = pos;
|
||||
let completionFrom: number;
|
||||
|
||||
if (
|
||||
node.name === "PropertyName" &&
|
||||
|
||||
@@ -11,6 +11,7 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-control-switch")
|
||||
@@ -39,7 +40,7 @@ export class HaControlSwitch extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.setupListeners();
|
||||
this.setupSwipeListeners();
|
||||
}
|
||||
|
||||
private _toggle() {
|
||||
@@ -50,7 +51,19 @@ export class HaControlSwitch extends LitElement {
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.setupListeners();
|
||||
this.setupSwipeListeners();
|
||||
}
|
||||
|
||||
updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
if (
|
||||
changedProperties.has("disabled") ||
|
||||
changedProperties.has("vertical") ||
|
||||
changedProperties.has("reversed")
|
||||
) {
|
||||
this.destroyListeners();
|
||||
this.setupSwipeListeners();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
@@ -61,7 +74,11 @@ export class HaControlSwitch extends LitElement {
|
||||
@query("#switch")
|
||||
private switch!: HTMLDivElement;
|
||||
|
||||
setupListeners() {
|
||||
setupSwipeListeners() {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.switch && !this._mc) {
|
||||
this._mc = new Manager(this.switch, {
|
||||
touchAction: this.touchAction ?? (this.vertical ? "pan-x" : "pan-y"),
|
||||
@@ -90,13 +107,15 @@ export class HaControlSwitch extends LitElement {
|
||||
} else {
|
||||
this._mc.on("swiperight", () => {
|
||||
if (this.disabled) return;
|
||||
this.checked = !this.reversed;
|
||||
const isRTL = mainWindow.document.dir === "rtl";
|
||||
this.checked = (!this.reversed && !isRTL) || (this.reversed && isRTL);
|
||||
fireEvent(this, "change");
|
||||
});
|
||||
|
||||
this._mc.on("swipeleft", () => {
|
||||
if (this.disabled) return;
|
||||
this.checked = !!this.reversed;
|
||||
const isRTL = mainWindow.document.dir === "rtl";
|
||||
this.checked = (this.reversed && !isRTL) || (!this.reversed && isRTL);
|
||||
fireEvent(this, "change");
|
||||
});
|
||||
}
|
||||
@@ -116,11 +135,30 @@ export class HaControlSwitch extends LitElement {
|
||||
}
|
||||
|
||||
private _keydown(ev: any) {
|
||||
if (ev.key !== "Enter" && ev.key !== " ") {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
const rtl = !this.vertical && mainWindow.document.dir === "rtl";
|
||||
const flip = this.reversed !== rtl;
|
||||
const [forward, backward] = this.vertical
|
||||
? ["ArrowDown", "ArrowUp"]
|
||||
: ["ArrowRight", "ArrowLeft"];
|
||||
const onKey = flip ? backward : forward;
|
||||
const offKey = flip ? forward : backward;
|
||||
|
||||
if (ev.key !== onKey && ev.key !== offKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
this._toggle();
|
||||
|
||||
const wantOn = ev.key === onKey;
|
||||
if (wantOn !== this.checked) {
|
||||
this._toggle();
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -132,7 +170,7 @@ export class HaControlSwitch extends LitElement {
|
||||
aria-checked=${this.checked ? "true" : "false"}
|
||||
aria-label=${ifDefined(this.label)}
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
tabindex=${ifDefined(this.disabled ? undefined : "0")}
|
||||
?checked=${this.checked}
|
||||
?disabled=${this.disabled}
|
||||
>
|
||||
@@ -156,6 +194,7 @@ export class HaControlSwitch extends LitElement {
|
||||
--control-switch-on-color: var(--primary-color);
|
||||
--control-switch-off-color: var(--disabled-color);
|
||||
--control-switch-background-opacity: 0.2;
|
||||
--control-switch-hover-background-opacity: 0.4;
|
||||
--control-switch-thickness: 40px;
|
||||
--control-switch-border-radius: var(--ha-border-radius-lg);
|
||||
--control-switch-padding: 4px;
|
||||
@@ -167,10 +206,10 @@ export class HaControlSwitch extends LitElement {
|
||||
transition: box-shadow 180ms ease-in-out;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.switch:focus-visible {
|
||||
.switch:not([disabled]):focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--control-switch-off-color);
|
||||
}
|
||||
.switch[checked]:focus-visible {
|
||||
.switch[checked]:not([disabled]):focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--control-switch-on-color);
|
||||
}
|
||||
.switch {
|
||||
@@ -199,6 +238,10 @@ export class HaControlSwitch extends LitElement {
|
||||
transition: background-color 180ms ease-in-out;
|
||||
opacity: var(--control-switch-background-opacity);
|
||||
}
|
||||
.switch:not([disabled]):focus-visible .background,
|
||||
.switch:not([disabled]):hover .background {
|
||||
opacity: var(--control-switch-hover-background-opacity);
|
||||
}
|
||||
.switch .button {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
@@ -222,12 +265,19 @@ export class HaControlSwitch extends LitElement {
|
||||
transform: translateX(100%);
|
||||
background-color: var(--control-switch-on-color);
|
||||
}
|
||||
.switch[checked] .button:dir(rtl) {
|
||||
transform: translateX(-100%);
|
||||
background-color: var(--control-switch-on-color);
|
||||
}
|
||||
:host([reversed]) .switch {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
:host([reversed]) .switch[checked] .button {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
:host([reversed]) .switch[checked] .button:dir(rtl) {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
:host([vertical]) {
|
||||
width: var(--control-switch-thickness);
|
||||
height: 100%;
|
||||
|
||||
@@ -445,10 +445,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
width: max(var(--body-width), 250px);
|
||||
width: var(--ha-generic-picker-width, max(var(--body-width), 250px));
|
||||
max-width: var(
|
||||
--ha-generic-picker-max-width,
|
||||
max(var(--body-width), 250px)
|
||||
var(--ha-generic-picker-width, max(var(--body-width), 250px))
|
||||
);
|
||||
max-height: 500px;
|
||||
height: 70vh;
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
} from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
} from "../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
|
||||
@@ -52,6 +55,7 @@ export interface PickerComboBoxItem {
|
||||
id: string;
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
disabled?: boolean;
|
||||
search_labels?: Record<string, string | null>;
|
||||
sorting_label?: string;
|
||||
icon_path?: string;
|
||||
@@ -64,6 +68,12 @@ export interface PickerComboBoxIndexSelectedDetail {
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
type PickerComboBoxRowElement = HTMLDivElement & {
|
||||
disabled?: boolean;
|
||||
index: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
|
||||
const PADDING_ID = "___padding___";
|
||||
|
||||
@@ -425,6 +435,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
class="combo-box-row ${this.value === item.id ? "current-value" : ""}"
|
||||
.value=${item.id}
|
||||
.index=${index}
|
||||
.disabled=${item.disabled}
|
||||
@click=${this._valueSelected}
|
||||
>
|
||||
${renderer(item, index)}
|
||||
@@ -437,10 +448,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
this._listScrolled = top > 0;
|
||||
}
|
||||
|
||||
private _valueSelected = (ev: MouseEvent) => {
|
||||
private _valueSelected = (
|
||||
ev: MouseEvent & HASSDomCurrentTargetEvent<PickerComboBoxRowElement>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const value = (ev.currentTarget as any).value as string;
|
||||
const index = Number((ev.currentTarget as any).index);
|
||||
const { disabled, index, value } = ev.currentTarget;
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
const newValue = value?.trim();
|
||||
const newTab = ev.ctrlKey || ev.metaKey;
|
||||
|
||||
@@ -728,7 +743,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
(
|
||||
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
|
||||
).forEach((item, index) => {
|
||||
if (typeof item !== "string") {
|
||||
if (typeof item !== "string" && !item.disabled) {
|
||||
this._fireSelectedEvents(item.id, index, newTab);
|
||||
}
|
||||
});
|
||||
@@ -748,7 +763,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
const item = this.virtualizerElement?.items[
|
||||
this._selectedItemIndex
|
||||
] as PickerComboBoxItem;
|
||||
if (item) {
|
||||
if (item && !item.disabled) {
|
||||
this._fireSelectedEvents(item.id, this._selectedItemIndex, newTab);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export class HaSelectorAttribute extends LitElement {
|
||||
}
|
||||
|
||||
// Validate that that the attribute is still valid for this entity, else unselect.
|
||||
let invalid = false;
|
||||
let invalid: boolean;
|
||||
if (this.context.filter_entity) {
|
||||
const entityIds = ensureArray(this.context.filter_entity);
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export class HaEntitySelector extends LitElement {
|
||||
.helper=${this.helper}
|
||||
.includeEntities=${this.selector.entity?.include_entities}
|
||||
.excludeEntities=${this.selector.entity?.exclude_entities}
|
||||
.extraOptions=${this.selector.entity?.extra_options}
|
||||
.entityFilter=${this._filterEntities}
|
||||
.createDomains=${this._createDomains}
|
||||
.disabled=${this.disabled}
|
||||
|
||||
@@ -99,7 +99,6 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.noEntity=${this.selector.state?.no_entity ?? false}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
allow-custom-value
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { UiClockDateFormatSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-clock-date-format-picker";
|
||||
|
||||
@customElement("ha-selector-ui_clock_date_format")
|
||||
export class HaSelectorUiClockDateFormat extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: UiClockDateFormatSelector;
|
||||
|
||||
@property() public value?: string | string[];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-clock-date-format-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-clock-date-format-picker>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-ui_clock_date_format": HaSelectorUiClockDateFormat;
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ const LOAD_ELEMENTS = {
|
||||
location: () => import("./ha-selector-location"),
|
||||
color_temp: () => import("./ha-selector-color-temp"),
|
||||
ui_action: () => import("./ha-selector-ui-action"),
|
||||
ui_clock_date_format: () => import("./ha-selector-ui-clock-date-format"),
|
||||
ui_color: () => import("./ha-selector-ui-color"),
|
||||
ui_state_content: () => import("./ha-selector-ui-state-content"),
|
||||
};
|
||||
|
||||
@@ -234,7 +234,10 @@ export class HaToast extends LitElement {
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
box-shadow: var(--wa-shadow-l);
|
||||
opacity: 0;
|
||||
transform: translate(-50%, var(--ha-space-2));
|
||||
transform: translate(
|
||||
calc(-50% * var(--scale-direction)),
|
||||
var(--ha-space-2)
|
||||
);
|
||||
transition:
|
||||
opacity var(--ha-animation-duration-fast, 150ms) ease,
|
||||
transform var(--ha-animation-duration-fast, 150ms) ease;
|
||||
@@ -242,7 +245,7 @@ export class HaToast extends LitElement {
|
||||
|
||||
.toast.visible {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
transform: translate(calc(-50% * var(--scale-direction)), 0);
|
||||
}
|
||||
|
||||
.toast:not(.active) {
|
||||
|
||||
@@ -602,7 +602,7 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
|
||||
// create icon
|
||||
let iconHTML = "";
|
||||
let iconHTML: string;
|
||||
if (icon) {
|
||||
const el = document.createElement("ha-icon");
|
||||
el.setAttribute("icon", icon);
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiMonitor } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { supportsFeature } from "../../common/entity/supports-feature";
|
||||
import {
|
||||
areasContext,
|
||||
configContext,
|
||||
devicesContext,
|
||||
entitiesContext,
|
||||
floorsContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../../data/context";
|
||||
import { UNAVAILABLE } from "../../data/entity/entity";
|
||||
import {
|
||||
entityComboBoxKeys,
|
||||
type EntityComboBoxItem,
|
||||
} from "../../data/entity/entity_picker";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import {
|
||||
BROWSER_PLAYER,
|
||||
type MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
} from "../../data/media-player";
|
||||
import "../entity/state-badge";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type {
|
||||
PickerComboBoxItem,
|
||||
PickerComboBoxSearchFn,
|
||||
} from "../ha-picker-combo-box";
|
||||
import "../ha-svg-icon";
|
||||
|
||||
interface BrowserPlayerComboBoxItem extends EntityComboBoxItem {
|
||||
id: typeof BROWSER_PLAYER;
|
||||
icon_path: string;
|
||||
stateObj?: never;
|
||||
}
|
||||
|
||||
interface MediaPlayerComboBoxItem extends EntityComboBoxItem {
|
||||
icon_path?: never;
|
||||
stateObj: MediaPlayerEntity;
|
||||
}
|
||||
|
||||
type PlayerComboBoxItem = BrowserPlayerComboBoxItem | MediaPlayerComboBoxItem;
|
||||
|
||||
@customElement("ha-media-player-picker")
|
||||
export class HaMediaPlayerPicker extends LitElement {
|
||||
@property() public value?: string;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@consume({ context: entitiesContext, subscribe: true })
|
||||
private _entities!: ContextType<typeof entitiesContext>;
|
||||
|
||||
@consume({ context: devicesContext, subscribe: true })
|
||||
private _devices!: ContextType<typeof devicesContext>;
|
||||
|
||||
@consume({ context: areasContext, subscribe: true })
|
||||
private _areas!: ContextType<typeof areasContext>;
|
||||
|
||||
@consume({ context: floorsContext, subscribe: true })
|
||||
private _floors!: ContextType<typeof floorsContext>;
|
||||
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig!: ContextType<typeof configContext>;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.value=${this.value}
|
||||
.getItems=${this._getPlayerItems}
|
||||
.rowRenderer=${this._playerRowRenderer}
|
||||
.searchFn=${this._playerSearchFn}
|
||||
.searchKeys=${entityComboBoxKeys}
|
||||
.notFoundLabel=${this._notFoundPlayerLabel}
|
||||
.popoverPlacement=${"top-end"}
|
||||
.hideClearIcon=${true}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
<slot name="field" slot="field" @click=${this.open}></slot>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
public open(ev?: Event): void {
|
||||
this._picker?.open(ev, { selectedValue: this.value });
|
||||
}
|
||||
|
||||
private _getPlayerItems = (): PlayerComboBoxItem[] => {
|
||||
const webBrowserLabel = this._i18n.localize(
|
||||
"ui.components.media-browser.web-browser"
|
||||
);
|
||||
const lang = this._i18n.language || "en";
|
||||
const isRTL =
|
||||
this._i18n.translationMetadata.translations[lang]?.isRTL || false;
|
||||
|
||||
return [
|
||||
{
|
||||
id: BROWSER_PLAYER,
|
||||
primary: webBrowserLabel,
|
||||
icon_path: mdiMonitor,
|
||||
search_labels: {
|
||||
entityName: webBrowserLabel,
|
||||
friendlyName: webBrowserLabel,
|
||||
deviceName: null,
|
||||
areaName: null,
|
||||
domainName: null,
|
||||
entityId: BROWSER_PLAYER,
|
||||
},
|
||||
},
|
||||
...Object.values(this._states)
|
||||
.filter(this._filterPlayerEntities)
|
||||
.map<MediaPlayerComboBoxItem>((stateObj) => {
|
||||
const friendlyName = computeStateName(stateObj);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const entityId = stateObj.entity_id;
|
||||
const domainName = domainToName(
|
||||
this._i18n.localize,
|
||||
computeDomain(entityId)
|
||||
);
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
return {
|
||||
id: entityId,
|
||||
primary,
|
||||
secondary,
|
||||
disabled: stateObj.state === UNAVAILABLE,
|
||||
domain_name: domainName,
|
||||
sorting_label: [primary, secondary].filter(Boolean).join("_"),
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
entityId,
|
||||
},
|
||||
stateObj,
|
||||
};
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
private _filterPlayerEntities = (
|
||||
entity: HassEntity
|
||||
): entity is MediaPlayerEntity =>
|
||||
computeStateDomain(entity) === "media_player" &&
|
||||
supportsFeature(entity, MediaPlayerEntityFeature.BROWSE_MEDIA) &&
|
||||
!this._entities[entity.entity_id]?.hidden;
|
||||
|
||||
private _playerRowRenderer: RenderItemFunction<PickerComboBoxItem> = (
|
||||
item: PickerComboBoxItem,
|
||||
index: number
|
||||
) => {
|
||||
const stateObj = this._isMediaPlayerItem(item) ? item.stateObj : undefined;
|
||||
|
||||
return html`
|
||||
<div
|
||||
style=${styleMap({
|
||||
width: "100%",
|
||||
borderTop: index === 0 ? undefined : "1px solid var(--divider-color)",
|
||||
})}
|
||||
>
|
||||
<ha-combo-box-item type="button" compact .disabled=${!!item.disabled}>
|
||||
${item.icon_path
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
style="margin: 0 4px"
|
||||
.path=${item.icon_path}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: html`
|
||||
<state-badge slot="start" .stateObj=${stateObj}></state-badge>
|
||||
`}
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing}
|
||||
${stateObj && this._hassConfig.userData?.showEntityIdPicker
|
||||
? html`
|
||||
<span slot="supporting-text" class="code">
|
||||
${stateObj.entity_id}
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
</ha-combo-box-item>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
private _playerSearchFn: PickerComboBoxSearchFn<PickerComboBoxItem> = (
|
||||
search: string,
|
||||
filteredItems: PickerComboBoxItem[]
|
||||
) => {
|
||||
const index = filteredItems.findIndex((item) => item.id === search);
|
||||
|
||||
if (index === -1) {
|
||||
return filteredItems;
|
||||
}
|
||||
|
||||
const [exactMatch] = filteredItems.splice(index, 1);
|
||||
filteredItems.unshift(exactMatch);
|
||||
return filteredItems;
|
||||
};
|
||||
|
||||
private _isMediaPlayerItem(
|
||||
item: PickerComboBoxItem
|
||||
): item is MediaPlayerComboBoxItem {
|
||||
return "stateObj" in item && item.stateObj !== undefined;
|
||||
}
|
||||
|
||||
private _notFoundPlayerLabel = (search: string) =>
|
||||
this._i18n.localize("ui.components.entity.entity-picker.no_match", {
|
||||
term: html`<b>${search}</b>`,
|
||||
});
|
||||
|
||||
private _valueChanged(ev: HASSDomEvent<{ value: string }>): void {
|
||||
ev.stopPropagation();
|
||||
if (!ev.detail.value) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-generic-picker {
|
||||
--ha-generic-picker-width: min(360px, calc(100vw - 32px));
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-media-player-picker": HaMediaPlayerPicker;
|
||||
}
|
||||
}
|
||||
@@ -428,7 +428,8 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
@@ -458,7 +459,8 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
|
||||
@@ -328,13 +328,12 @@ const describeLegacyTrigger = (
|
||||
let fromChoice = "other";
|
||||
let fromString = "";
|
||||
if (trigger.from !== undefined) {
|
||||
let fromArray: string[] = [];
|
||||
if (trigger.from === null) {
|
||||
if (!trigger.attribute) {
|
||||
fromChoice = "null";
|
||||
}
|
||||
} else {
|
||||
fromArray = ensureArray(trigger.from);
|
||||
const fromArray = ensureArray(trigger.from);
|
||||
|
||||
const from: string[] = [];
|
||||
for (const state of fromArray) {
|
||||
@@ -362,13 +361,12 @@ const describeLegacyTrigger = (
|
||||
let toChoice = "other";
|
||||
let toString = "";
|
||||
if (trigger.to !== undefined) {
|
||||
let toArray: string[] = [];
|
||||
if (trigger.to === null) {
|
||||
if (!trigger.attribute) {
|
||||
toChoice = "null";
|
||||
}
|
||||
} else {
|
||||
toArray = ensureArray(trigger.to);
|
||||
const toArray = ensureArray(trigger.to);
|
||||
|
||||
const to: string[] = [];
|
||||
for (const state of toArray) {
|
||||
@@ -521,7 +519,7 @@ const describeLegacyTrigger = (
|
||||
| "every_interval"
|
||||
| "on_the_xth"
|
||||
| "other"
|
||||
| "has_seconds_or_minutes" = "other";
|
||||
| "has_seconds_or_minutes";
|
||||
|
||||
let seconds = 0;
|
||||
let minutes = 0;
|
||||
|
||||
+6
-18
@@ -1235,13 +1235,7 @@ export const computeConsumptionSingle = (data: {
|
||||
(to_grid || 0) -
|
||||
(to_battery || 0);
|
||||
|
||||
let used_solar = 0;
|
||||
let grid_to_battery = 0;
|
||||
let battery_to_grid = 0;
|
||||
let solar_to_battery = 0;
|
||||
let solar_to_grid = 0;
|
||||
let used_battery = 0;
|
||||
let used_grid = 0;
|
||||
|
||||
let used_total_remaining = Math.max(used_total, 0);
|
||||
// Consumption Priority
|
||||
@@ -1266,40 +1260,34 @@ export const computeConsumptionSingle = (data: {
|
||||
|
||||
// Fill the remainder of the battery input from solar
|
||||
// Solar -> Battery_In
|
||||
solar_to_battery = Math.min(solar, to_battery);
|
||||
const solar_to_battery = Math.min(solar, to_battery);
|
||||
to_battery -= solar_to_battery;
|
||||
solar -= solar_to_battery;
|
||||
|
||||
// Solar -> Grid_Out
|
||||
solar_to_grid = Math.min(solar, to_grid);
|
||||
const solar_to_grid = Math.min(solar, to_grid);
|
||||
to_grid -= solar_to_grid;
|
||||
solar -= solar_to_grid;
|
||||
|
||||
// Battery_Out -> Grid_Out
|
||||
battery_to_grid = Math.min(from_battery, to_grid);
|
||||
const battery_to_grid = Math.min(from_battery, to_grid);
|
||||
from_battery -= battery_to_grid;
|
||||
to_grid -= battery_to_grid;
|
||||
|
||||
// Grid_In -> Battery_In (second pass)
|
||||
const grid_to_battery_2 = Math.min(from_grid, to_battery);
|
||||
grid_to_battery += grid_to_battery_2;
|
||||
from_grid -= grid_to_battery_2;
|
||||
to_battery -= grid_to_battery_2;
|
||||
|
||||
// Solar -> Consumption
|
||||
used_solar = Math.min(used_total_remaining, solar);
|
||||
const used_solar = Math.min(used_total_remaining, solar);
|
||||
used_total_remaining -= used_solar;
|
||||
solar -= used_solar;
|
||||
|
||||
// Battery_Out -> Consumption
|
||||
used_battery = Math.min(from_battery, used_total_remaining);
|
||||
from_battery -= used_battery;
|
||||
const used_battery = Math.min(from_battery, used_total_remaining);
|
||||
used_total_remaining -= used_battery;
|
||||
|
||||
// Grid_In -> Consumption
|
||||
used_grid = Math.min(used_total_remaining, from_grid);
|
||||
from_grid -= used_grid;
|
||||
used_total_remaining -= from_grid;
|
||||
const used_grid = Math.min(used_total_remaining, from_grid);
|
||||
|
||||
return {
|
||||
used_solar,
|
||||
|
||||
@@ -187,6 +187,30 @@ export const NON_NUMERIC_ATTRIBUTES = [
|
||||
"xy_color",
|
||||
];
|
||||
|
||||
export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
|
||||
"access_token",
|
||||
"available_modes",
|
||||
"color_modes",
|
||||
"editable",
|
||||
"effect_list",
|
||||
"entity_picture",
|
||||
"event_types",
|
||||
"fan_modes",
|
||||
"fan_speed_list",
|
||||
"forecast",
|
||||
"friendly_name",
|
||||
"hvac_modes",
|
||||
"icon",
|
||||
"operation_list",
|
||||
"options",
|
||||
"preset_modes",
|
||||
"sound_mode_list",
|
||||
"source_list",
|
||||
"state_class",
|
||||
"swing_modes",
|
||||
"token",
|
||||
];
|
||||
|
||||
export const computeShownAttributes = (stateObj: HassEntity) => {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const filtersArray = STATE_ATTRIBUTES.concat(
|
||||
|
||||
@@ -53,7 +53,7 @@ export const getEntities = (
|
||||
value?: string,
|
||||
idPrefix = ""
|
||||
): EntityComboBoxItem[] => {
|
||||
let items: EntityComboBoxItem[] = [];
|
||||
let items: EntityComboBoxItem[];
|
||||
|
||||
let entityIds = Object.keys(hass.states);
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
mdiThermostat,
|
||||
mdiTimerOutline,
|
||||
mdiToggleSwitch,
|
||||
mdiVideoInputAntenna,
|
||||
mdiWater,
|
||||
mdiWaterPercent,
|
||||
mdiWeatherPartlyCloudy,
|
||||
@@ -128,6 +129,7 @@ export const FALLBACK_DOMAIN_ICONS = {
|
||||
plant: mdiFlower,
|
||||
power: mdiFlash,
|
||||
proximity: mdiAppleSafari,
|
||||
radio_frequency: mdiVideoInputAntenna,
|
||||
remote: mdiRemote,
|
||||
scene: mdiPalette,
|
||||
schedule: mdiCalendarClock,
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
mdiPlayPause,
|
||||
mdiPodcast,
|
||||
mdiPower,
|
||||
mdiPowerOff,
|
||||
mdiPowerOn,
|
||||
mdiRepeat,
|
||||
mdiRepeatOff,
|
||||
mdiRepeatOnce,
|
||||
@@ -286,7 +288,9 @@ export const computeMediaControls = (
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!stateActive(stateObj)) {
|
||||
const assumedState = stateObj.attributes.assumed_state === true;
|
||||
|
||||
if (!stateActive(stateObj) && !assumedState) {
|
||||
return supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_ON)
|
||||
? [
|
||||
{
|
||||
@@ -299,14 +303,23 @@ export const computeMediaControls = (
|
||||
|
||||
const buttons: ControlButton[] = [];
|
||||
|
||||
if (
|
||||
assumedState &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_ON)
|
||||
) {
|
||||
buttons.push({
|
||||
icon: mdiPowerOn,
|
||||
action: "turn_on",
|
||||
});
|
||||
}
|
||||
|
||||
if (supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_OFF)) {
|
||||
buttons.push({
|
||||
icon: mdiPower,
|
||||
icon: assumedState ? mdiPowerOff : mdiPower,
|
||||
action: "turn_off",
|
||||
});
|
||||
}
|
||||
|
||||
const assumedState = stateObj.attributes.assumed_state === true;
|
||||
const stateAttr = stateObj.attributes;
|
||||
|
||||
if (
|
||||
|
||||
@@ -16,6 +16,7 @@ export const SCENE_IGNORED_DOMAINS = [
|
||||
"input_button",
|
||||
"persistent_notification",
|
||||
"person",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
"schedule",
|
||||
"script",
|
||||
|
||||
+16
-1
@@ -79,6 +79,7 @@ export type Selector =
|
||||
| TTSVoiceSelector
|
||||
| SerialPortSelector
|
||||
| UiActionSelector
|
||||
| UiClockDateFormatSelector
|
||||
| UiColorSelector
|
||||
| UiStateContentSelector
|
||||
| BackupLocationSelector;
|
||||
@@ -249,6 +250,16 @@ interface EntitySelectorFilter {
|
||||
unit_of_measurement?: string | readonly string[];
|
||||
}
|
||||
|
||||
export interface EntitySelectorExtraOption {
|
||||
id: string;
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
icon?: string;
|
||||
icon_path?: string;
|
||||
entity_id?: string;
|
||||
hide_clear?: boolean;
|
||||
}
|
||||
|
||||
export interface EntitySelector {
|
||||
entity: {
|
||||
multiple?: boolean;
|
||||
@@ -256,6 +267,7 @@ export interface EntitySelector {
|
||||
exclude_entities?: string[];
|
||||
filter?: EntitySelectorFilter | readonly EntitySelectorFilter[];
|
||||
reorder?: boolean;
|
||||
extra_options?: EntitySelectorExtraOption[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -463,7 +475,6 @@ export interface StateSelector {
|
||||
attribute?: string;
|
||||
hide_states?: string[];
|
||||
multiple?: boolean;
|
||||
no_entity?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -549,6 +560,10 @@ export interface UiActionSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface UiClockDateFormatSelector {
|
||||
ui_clock_date_format: {} | null;
|
||||
}
|
||||
|
||||
export interface UiColorExtraOption {
|
||||
value: string;
|
||||
label: string;
|
||||
|
||||
+27
-20
@@ -1,4 +1,5 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
|
||||
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
|
||||
@@ -58,23 +59,26 @@ export const extractFromTarget = async (
|
||||
primary_entities_only: primaryEntitiesOnly,
|
||||
});
|
||||
|
||||
export const getResolvedTargetEntityCount = async (
|
||||
hass: HomeAssistant,
|
||||
target?: HassServiceTarget
|
||||
): Promise<number | undefined> => {
|
||||
if (!target) {
|
||||
return undefined;
|
||||
export const getTargetEntityCount = (target?: HassServiceTarget): number => {
|
||||
const tempTarget = {
|
||||
entity_id: target?.entity_id ? ensureArray(target?.entity_id) : [],
|
||||
device_id: target?.device_id ? ensureArray(target?.device_id) : [],
|
||||
area_id: target?.area_id ? ensureArray(target?.area_id) : [],
|
||||
floor_id: target?.floor_id ? ensureArray(target?.floor_id) : [],
|
||||
label_id: target?.label_id ? ensureArray(target?.label_id) : [],
|
||||
};
|
||||
|
||||
if (
|
||||
tempTarget?.device_id?.length > 0 ||
|
||||
tempTarget?.area_id?.length > 0 ||
|
||||
tempTarget?.floor_id?.length > 0 ||
|
||||
tempTarget?.label_id?.length > 0
|
||||
) {
|
||||
// if targeting non entities the number of entities is dynamic
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
try {
|
||||
return (await extractFromTarget(hass, target, true)).referenced_entities
|
||||
.length;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error resolving target entity count", err);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return tempTarget?.entity_id?.length;
|
||||
};
|
||||
|
||||
export const getTriggersForTarget = async (
|
||||
@@ -118,7 +122,8 @@ export const areaMeetsFilter = (
|
||||
includeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
states?: HomeAssistant["states"],
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
includeSecondary = false
|
||||
): boolean => {
|
||||
const areaDevices = Object.values(devices).filter(
|
||||
(device) => device.area_id === area.area_id
|
||||
@@ -133,7 +138,8 @@ export const areaMeetsFilter = (
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
states,
|
||||
entityFilter
|
||||
entityFilter,
|
||||
includeSecondary
|
||||
)
|
||||
)
|
||||
) {
|
||||
@@ -148,7 +154,7 @@ export const areaMeetsFilter = (
|
||||
areaEntities.some((entity) =>
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
false,
|
||||
includeSecondary,
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
states,
|
||||
@@ -169,7 +175,8 @@ export const deviceMeetsFilter = (
|
||||
includeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
states?: HomeAssistant["states"],
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
includeSecondary = false
|
||||
): boolean => {
|
||||
const devEntities = Object.values(entities).filter(
|
||||
(entity) => entity.device_id === device.id
|
||||
@@ -179,7 +186,7 @@ export const deviceMeetsFilter = (
|
||||
!devEntities.some((entity) =>
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
false,
|
||||
includeSecondary,
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
states,
|
||||
|
||||
+1
-1
@@ -67,6 +67,7 @@ export type ActionTraceStep =
|
||||
|
||||
interface BaseTrace {
|
||||
domain: string;
|
||||
error?: string;
|
||||
item_id: string;
|
||||
last_step: string | null;
|
||||
run_id: string;
|
||||
@@ -97,7 +98,6 @@ interface BaseTrace {
|
||||
interface BaseTraceExtended {
|
||||
trace: Record<string, ActionTraceStep[]>;
|
||||
context: Context;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AutomationTrace extends BaseTrace {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface CommonControlResult {
|
||||
export interface CommonControlsResult {
|
||||
entities: string[];
|
||||
}
|
||||
|
||||
export const getCommonControlUsagePrediction = (hass: HomeAssistant) =>
|
||||
hass.callWS<CommonControlResult>({
|
||||
export const getCommonControlsUsagePrediction = (hass: HomeAssistant) =>
|
||||
hass.callWS<CommonControlsResult>({
|
||||
type: "usage_prediction/common_control",
|
||||
});
|
||||
|
||||
@@ -57,6 +57,16 @@ export interface ForecastAttribute {
|
||||
wind_speed?: string;
|
||||
}
|
||||
|
||||
export type ForecastPrecipitationType = "amount" | "probability";
|
||||
|
||||
export const getForecastPrecipitation = (
|
||||
entry: ForecastAttribute,
|
||||
type: ForecastPrecipitationType
|
||||
) =>
|
||||
type === "probability"
|
||||
? entry.precipitation_probability
|
||||
: entry.precipitation;
|
||||
|
||||
interface WeatherEntityAttributes extends HassEntityAttributeBase {
|
||||
attribution?: string;
|
||||
humidity?: number;
|
||||
|
||||
@@ -495,8 +495,12 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
private _goToAddEntityTo(ev) {
|
||||
// Only check for request-selected events (from menu items), not regular clicks (from icon button)
|
||||
if (ev.type === "request-selected" && !shouldHandleRequestSelectedEvent(ev))
|
||||
if (
|
||||
ev.type === "request-selected" &&
|
||||
!shouldHandleRequestSelectedEvent(ev)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._setView("add_to");
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,11 @@ class MoreInfoContent extends LitElement {
|
||||
|
||||
if (!moreInfoType) return nothing;
|
||||
|
||||
const memberIds = this._getEntityMemberIds(this.stateObj);
|
||||
const memberIds = this._getEntityMemberIds(
|
||||
this.stateObj,
|
||||
this.entry,
|
||||
this.hass.entities
|
||||
);
|
||||
|
||||
return html`
|
||||
${dynamicElement(moreInfoType, {
|
||||
@@ -75,23 +79,27 @@ class MoreInfoContent extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _getEntityMemberIds(stateObj: HassEntity): string[] | undefined {
|
||||
if (computeStateDomain(stateObj) === "group") {
|
||||
// Don't show entity members for legacy groups as they already show
|
||||
// the members in their more info dialog.
|
||||
return undefined;
|
||||
private _getEntityMemberIds = memoizeOne(
|
||||
(
|
||||
stateObj: HassEntity,
|
||||
entry: ExtEntityRegistryEntry | null | undefined,
|
||||
entities: HomeAssistant["entities"]
|
||||
): string[] | undefined => {
|
||||
if (computeStateDomain(stateObj) === "group") {
|
||||
// Don't show entity members for legacy groups as they already show
|
||||
// the members in their more info dialog.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const memberIds =
|
||||
(entry?.capabilities?.group_entities as string[] | undefined) ??
|
||||
(Array.isArray(stateObj.attributes.entity_id)
|
||||
? (stateObj.attributes.entity_id as string[])
|
||||
: undefined);
|
||||
|
||||
return memberIds?.filter((entityId) => !entities[entityId]?.hidden);
|
||||
}
|
||||
|
||||
const memberIds =
|
||||
(this.entry?.capabilities?.group_entities as string[] | undefined) ??
|
||||
(Array.isArray(stateObj.attributes.entity_id)
|
||||
? (stateObj.attributes.entity_id as string[])
|
||||
: undefined);
|
||||
|
||||
return memberIds?.filter(
|
||||
(entityId) => !this.hass!.entities[entityId]?.hidden
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _entitiesSectionConfig = memoizeOne((entityIds: string[]) => {
|
||||
const hass = this.hass!;
|
||||
|
||||
@@ -456,10 +456,7 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
|
||||
);
|
||||
let i = 1;
|
||||
while (
|
||||
pipelines.pipelines.find(
|
||||
// eslint-disable-next-line no-loop-func
|
||||
(pipeline) => pipeline.name === pipelineName
|
||||
)
|
||||
pipelines.pipelines.find((pipeline) => pipeline.name === pipelineName)
|
||||
) {
|
||||
pipelineName = `${this.hass.localize(`ui.panel.config.voice_assistants.satellite_wizard.local.${this.localOption}_pipeline`)} ${i}`;
|
||||
i++;
|
||||
|
||||
@@ -343,10 +343,7 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
|
||||
let pipelineName = "Home Assistant Cloud";
|
||||
let i = 1;
|
||||
while (
|
||||
pipelines.pipelines.find(
|
||||
// eslint-disable-next-line no-loop-func
|
||||
(pipeline) => pipeline.name === pipelineName
|
||||
)
|
||||
pipelines.pipelines.find((pipeline) => pipeline.name === pipelineName)
|
||||
) {
|
||||
pipelineName = `Home Assistant Cloud ${i}`;
|
||||
i++;
|
||||
|
||||
@@ -283,8 +283,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
.label=${localize("ui.components.subpage-data-table.sort_by", {
|
||||
sortColumn:
|
||||
this._sortColumn && this.columns[this._sortColumn]
|
||||
? ` ${this.columns[this._sortColumn].title || this.columns[this._sortColumn].label}` ||
|
||||
""
|
||||
? ` ${this.columns[this._sortColumn].title || this.columns[this._sortColumn].label}`
|
||||
: "",
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -74,7 +74,10 @@ export const ConditionalListenerMixin = <
|
||||
protected willUpdate(changedProperties: PropertyValues) {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("_maxColumns")) {
|
||||
this._conditionContext = { max_columns: this._maxColumns };
|
||||
this._conditionContext = {
|
||||
...this._conditionContext,
|
||||
max_columns: this._maxColumns,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import {
|
||||
mdiDelete,
|
||||
mdiDotsVertical,
|
||||
mdiFloorPlan,
|
||||
mdiHelpCircleOutline,
|
||||
mdiPencil,
|
||||
mdiPlus,
|
||||
mdiSofa,
|
||||
mdiSort,
|
||||
} from "@mdi/js";
|
||||
import {
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
@@ -88,8 +88,6 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
|
||||
@state() private _hierarchy?: AreasFloorHierarchy;
|
||||
|
||||
@query("wa-popover") private _popover?: WaPopover;
|
||||
|
||||
private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _blockHierarchyUpdate = false;
|
||||
@@ -322,26 +320,20 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<ha-button id="fab" slot="fab" size="large">
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.common.add")}
|
||||
</ha-button>
|
||||
<wa-popover
|
||||
trap-focus
|
||||
placement="top-start"
|
||||
distance="8"
|
||||
without-arrow
|
||||
for="fab"
|
||||
>
|
||||
<ha-button appearance="filled" @click=${this._createFloor}>
|
||||
<ha-dropdown slot="fab" @wa-select=${this._handleCreateAction}>
|
||||
<ha-button slot="trigger" id="fab" size="large">
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.common.add")}
|
||||
</ha-button>
|
||||
<ha-dropdown-item value="create_floor">
|
||||
<ha-svg-icon .path=${mdiFloorPlan} slot="icon"></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.areas.picker.create_floor")}
|
||||
</ha-button>
|
||||
<ha-button appearance="filled" @click=${this._createArea}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item value="create_area">
|
||||
<ha-svg-icon .path=${mdiSofa} slot="icon"></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.areas.picker.create_area")}
|
||||
</ha-button>
|
||||
</wa-popover>
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
</hass-tabs-subpage>
|
||||
`;
|
||||
}
|
||||
@@ -561,8 +553,16 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleCreateAction(ev: HaDropdownSelectEvent) {
|
||||
const action = ev.detail.item.value;
|
||||
if (action === "create_floor") {
|
||||
this._createFloor();
|
||||
} else if (action === "create_area") {
|
||||
this._createArea();
|
||||
}
|
||||
}
|
||||
|
||||
private _createFloor() {
|
||||
this._popover?.hide();
|
||||
this._openFloorDialog();
|
||||
}
|
||||
|
||||
@@ -588,7 +588,6 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
}
|
||||
|
||||
private _createArea() {
|
||||
this._popover?.hide();
|
||||
this._openAreaDialog();
|
||||
}
|
||||
|
||||
@@ -730,14 +729,6 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
align-items: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
gap: var(--ha-space-2);
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -249,10 +249,14 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
"target" in
|
||||
(this.hass.services?.[computeDomain(action)]?.[
|
||||
computeObjectId(action)
|
||||
] || {});
|
||||
] || {}) &&
|
||||
// special case for reload config entry as it has an optional target but mainly uses entry_id
|
||||
((this.action as ServiceAction).action !==
|
||||
"homeassistant.reload_config_entry" ||
|
||||
!(this.action as ServiceAction).data?.entry_id);
|
||||
|
||||
const target = actionHasTarget
|
||||
? (this.action as ServiceAction).target
|
||||
? this._extractTargets(this.action as ServiceAction)
|
||||
: type === "device_id" && (this.action as DeviceAction).device_id
|
||||
? { device_id: (this.action as DeviceAction).device_id }
|
||||
: undefined;
|
||||
@@ -617,6 +621,18 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _extractTargets(action: ServiceAction): HassServiceTarget {
|
||||
if (action.target) {
|
||||
return action.target;
|
||||
}
|
||||
|
||||
// legacy support for entity_id
|
||||
if (action.entity_id) {
|
||||
return { entity_id: action.entity_id };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private _renderTargets = memoizeOne(
|
||||
(target?: HassServiceTarget, targetRequired = false) =>
|
||||
html`<ha-automation-row-targets
|
||||
|
||||
@@ -138,7 +138,7 @@ export class HaConditionAction
|
||||
const isDynamicValue = isDynamic(value);
|
||||
const condition = isDynamicValue ? getValueFromDynamic(value) : value;
|
||||
|
||||
let label = condition;
|
||||
let label: string;
|
||||
|
||||
if (isDynamicValue) {
|
||||
const domain = getConditionDomain(condition);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import type { IntegrationManifest } from "../../../../../data/integration";
|
||||
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||
import type { TargetSelector } from "../../../../../data/selector";
|
||||
import { getResolvedTargetEntityCount } from "../../../../../data/target";
|
||||
import { getTargetEntityCount } from "../../../../../data/target";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
|
||||
@@ -432,35 +432,13 @@ export class HaPlatformCondition extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveTargetEntityCount = memoizeOne(
|
||||
async (target: PlatformCondition["target"]) =>
|
||||
getResolvedTargetEntityCount(this.hass, target)
|
||||
);
|
||||
|
||||
private async _updateResolvedTargetEntityCount(
|
||||
private _updateResolvedTargetEntityCount(
|
||||
target: PlatformCondition["target"]
|
||||
) {
|
||||
this._resolvedTargetEntityCount =
|
||||
await this._resolveTargetEntityCount(target);
|
||||
this._resolvedTargetEntityCount = getTargetEntityCount(target);
|
||||
|
||||
if (
|
||||
(!target ||
|
||||
(this._resolvedTargetEntityCount !== undefined &&
|
||||
this._resolvedTargetEntityCount <= 1)) &&
|
||||
this.condition.options?.behavior !== undefined
|
||||
) {
|
||||
const options = { ...this.condition.options };
|
||||
delete options.behavior;
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.condition,
|
||||
options,
|
||||
},
|
||||
});
|
||||
} else if (
|
||||
target &&
|
||||
this._resolvedTargetEntityCount !== undefined &&
|
||||
this._resolvedTargetEntityCount > 1 &&
|
||||
this.condition.options?.behavior === undefined
|
||||
) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { StateCondition } from "../../../../../data/automation";
|
||||
import { STATE_CONDITION_HIDDEN_ATTRIBUTES } from "../../../../../data/entity/entity_attributes";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { forDictStruct } from "../../structs";
|
||||
import type { ConditionElement } from "../ha-automation-condition-row";
|
||||
@@ -38,29 +39,7 @@ const SCHEMA = [
|
||||
name: "attribute",
|
||||
selector: {
|
||||
attribute: {
|
||||
hide_attributes: [
|
||||
"access_token",
|
||||
"available_modes",
|
||||
"color_modes",
|
||||
"editable",
|
||||
"effect_list",
|
||||
"entity_picture",
|
||||
"event_types",
|
||||
"fan_modes",
|
||||
"fan_speed_list",
|
||||
"forecast",
|
||||
"friendly_name",
|
||||
"hvac_modes",
|
||||
"icon",
|
||||
"operation_list",
|
||||
"options",
|
||||
"preset_modes",
|
||||
"sound_mode_list",
|
||||
"source_list",
|
||||
"state_class",
|
||||
"swing_modes",
|
||||
"token",
|
||||
],
|
||||
hide_attributes: STATE_CONDITION_HIDDEN_ATTRIBUTES,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import {
|
||||
mdiAlertCircleOutline,
|
||||
mdiCheckCircleOutline,
|
||||
mdiChevronDown,
|
||||
mdiDotsVertical,
|
||||
mdiDownload,
|
||||
mdiHelpCircleOutline,
|
||||
mdiInformationOutline,
|
||||
mdiPencil,
|
||||
mdiProgressClock,
|
||||
mdiProgressWrench,
|
||||
mdiRayEndArrow,
|
||||
mdiRayStartArrow,
|
||||
mdiRefresh,
|
||||
mdiStopCircleOutline,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
@@ -47,6 +53,9 @@ import "../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import "../../../components/ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
|
||||
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
|
||||
|
||||
const TABS = ["details", "timeline", "logbook", "automation_config"] as const;
|
||||
|
||||
@@ -78,6 +87,8 @@ export class HaAutomationTrace extends LitElement {
|
||||
|
||||
@state() private _view: (typeof TABS)[number] | "blueprint" = "details";
|
||||
|
||||
@query("ha-generic-picker") private tracePicker?: HaGenericPicker;
|
||||
|
||||
@query("hat-script-graph") private _graph?: HatScriptGraph;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -180,20 +191,32 @@ export class HaAutomationTrace extends LitElement {
|
||||
this._runId}
|
||||
@click=${this._pickOlderTrace}
|
||||
></ha-icon-button>
|
||||
<select .value=${this._runId} @change=${this._pickTrace}>
|
||||
${repeat(
|
||||
this._traces,
|
||||
(trace) => trace.run_id,
|
||||
(trace) =>
|
||||
html`<option value=${trace.run_id}>
|
||||
${formatDateTimeWithSeconds(
|
||||
new Date(trace.timestamp.start),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}
|
||||
</option>`
|
||||
<ha-generic-picker
|
||||
name="trace"
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.trace.select_trace"
|
||||
)}
|
||||
</select>
|
||||
.value=${this._runId}
|
||||
.getItems=${this._getTraces}
|
||||
required
|
||||
@value-changed=${this._pickTrace}
|
||||
>
|
||||
<ha-button
|
||||
slot="field"
|
||||
appearance="filled"
|
||||
variant="neutral"
|
||||
size="small"
|
||||
@click=${this._openPicker}
|
||||
>
|
||||
${this._renderTracePickerValue(this._runId!)}
|
||||
<ha-svg-icon
|
||||
slot="end"
|
||||
.path=${mdiChevronDown}
|
||||
></ha-svg-icon>
|
||||
</ha-button>
|
||||
</ha-generic-picker>
|
||||
|
||||
<ha-icon-button
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.automation.trace.newer_trace"
|
||||
@@ -321,6 +344,118 @@ export class HaAutomationTrace extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _openPicker(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this.tracePicker?.open();
|
||||
}
|
||||
|
||||
private _getTraces = (): PickerComboBoxItem[] =>
|
||||
this._traces?.map((trace) => {
|
||||
const renderRuntime = () =>
|
||||
(
|
||||
(new Date(trace.timestamp.finish!).getTime() -
|
||||
new Date(trace.timestamp.start).getTime()) /
|
||||
1000
|
||||
).toFixed(2);
|
||||
|
||||
const item: PickerComboBoxItem = {
|
||||
id: trace.run_id,
|
||||
primary: formatDateTimeWithSeconds(
|
||||
new Date(trace.timestamp.start),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
};
|
||||
if (trace.state === "running") {
|
||||
item.secondary = this.hass.localize(
|
||||
"ui.panel.config.automation.trace.picker.still_running"
|
||||
);
|
||||
item.icon_path = mdiProgressClock;
|
||||
} else if (trace.state === "debugged") {
|
||||
item.secondary = this.hass.localize(
|
||||
"ui.panel.config.automation.trace.picker.debugged"
|
||||
);
|
||||
item.icon_path = mdiProgressWrench;
|
||||
} else if (trace.script_execution === "finished") {
|
||||
item.secondary = this.hass.localize(
|
||||
"ui.panel.config.automation.trace.picker.finished",
|
||||
{
|
||||
executiontime: renderRuntime(),
|
||||
}
|
||||
);
|
||||
item.icon_path = mdiCheckCircleOutline;
|
||||
} else if (trace.script_execution === "aborted") {
|
||||
item.secondary = this.hass.localize(
|
||||
"ui.panel.config.automation.trace.picker.aborted",
|
||||
{
|
||||
executiontime: renderRuntime(),
|
||||
}
|
||||
);
|
||||
item.icon_path = mdiAlertCircleOutline;
|
||||
} else if (trace.script_execution === "cancelled") {
|
||||
item.secondary = this.hass.localize(
|
||||
"ui.panel.config.automation.trace.picker.cancelled",
|
||||
{
|
||||
executiontime: renderRuntime(),
|
||||
}
|
||||
);
|
||||
item.icon_path = mdiAlertCircleOutline;
|
||||
} else {
|
||||
let message:
|
||||
| "stopped_failed_conditions"
|
||||
| "stopped_failed_single"
|
||||
| "stopped_failed_max_runs"
|
||||
| "stopped_error"
|
||||
| "stopped_unknown_reason";
|
||||
let error: string | undefined;
|
||||
let icon: string;
|
||||
|
||||
switch (trace.script_execution) {
|
||||
case "failed_conditions":
|
||||
message = "stopped_failed_conditions";
|
||||
icon = mdiStopCircleOutline;
|
||||
break;
|
||||
case "failed_single":
|
||||
message = "stopped_failed_single";
|
||||
icon = mdiStopCircleOutline;
|
||||
break;
|
||||
case "failed_max_runs":
|
||||
message = "stopped_failed_max_runs";
|
||||
icon = mdiStopCircleOutline;
|
||||
break;
|
||||
case "error":
|
||||
message = "stopped_error";
|
||||
error = trace.error!;
|
||||
icon = mdiAlertCircleOutline;
|
||||
break;
|
||||
default:
|
||||
message = "stopped_unknown_reason";
|
||||
icon = mdiHelpCircleOutline;
|
||||
}
|
||||
|
||||
item.secondary = this.hass.localize(
|
||||
`ui.panel.config.automation.trace.picker.${message}`,
|
||||
{
|
||||
error,
|
||||
executiontime: renderRuntime(),
|
||||
}
|
||||
);
|
||||
item.icon_path = icon;
|
||||
}
|
||||
return item;
|
||||
}) ?? [];
|
||||
|
||||
private _renderTracePickerValue = (runId: string) => {
|
||||
const trace = this._traces?.find((t) => t.run_id === runId);
|
||||
return html`${trace
|
||||
? formatDateTimeWithSeconds(
|
||||
new Date(trace.timestamp.start),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)
|
||||
: runId}`;
|
||||
};
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
|
||||
@@ -378,7 +513,7 @@ export class HaAutomationTrace extends LitElement {
|
||||
}
|
||||
|
||||
private _pickTrace(ev) {
|
||||
this._runId = ev.target.value;
|
||||
this._runId = ev.detail.value;
|
||||
this._selected = undefined;
|
||||
}
|
||||
|
||||
@@ -618,6 +753,13 @@ export class HaAutomationTrace extends LitElement {
|
||||
ha-trace-logbook {
|
||||
direction: var(--direction);
|
||||
}
|
||||
ha-generic-picker {
|
||||
flex-grow: 1;
|
||||
max-width: 500px;
|
||||
}
|
||||
ha-generic-picker > ha-button {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { PlatformTrigger } from "../../../../../data/automation";
|
||||
import type { IntegrationManifest } from "../../../../../data/integration";
|
||||
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||
import type { TargetSelector } from "../../../../../data/selector";
|
||||
import { getResolvedTargetEntityCount } from "../../../../../data/target";
|
||||
import { getTargetEntityCount } from "../../../../../data/target";
|
||||
import {
|
||||
getTriggerDomain,
|
||||
getTriggerObjectId,
|
||||
@@ -467,35 +467,11 @@ export class HaPlatformTrigger extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveTargetEntityCount = memoizeOne(
|
||||
async (target: PlatformTrigger["target"]) =>
|
||||
getResolvedTargetEntityCount(this.hass, target)
|
||||
);
|
||||
|
||||
private async _updateResolvedTargetEntityCount(
|
||||
target: PlatformTrigger["target"]
|
||||
) {
|
||||
this._resolvedTargetEntityCount =
|
||||
await this._resolveTargetEntityCount(target);
|
||||
private _updateResolvedTargetEntityCount(target: PlatformTrigger["target"]) {
|
||||
this._resolvedTargetEntityCount = getTargetEntityCount(target);
|
||||
|
||||
if (
|
||||
(!target ||
|
||||
(this._resolvedTargetEntityCount !== undefined &&
|
||||
this._resolvedTargetEntityCount <= 1)) &&
|
||||
this.trigger.options?.behavior !== undefined
|
||||
) {
|
||||
const options = { ...this.trigger.options };
|
||||
delete options.behavior;
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.trigger,
|
||||
options,
|
||||
},
|
||||
});
|
||||
} else if (
|
||||
target &&
|
||||
this._resolvedTargetEntityCount !== undefined &&
|
||||
this._resolvedTargetEntityCount > 1 &&
|
||||
this.trigger.options?.behavior === undefined
|
||||
) {
|
||||
|
||||
@@ -85,7 +85,9 @@ class DialogImportBlueprint extends LitElement {
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
<span slot="title" @click=${this._enlarge}> ${heading} </span>
|
||||
<span slot="title" class="title-enlargeable" @click=${this._enlarge}>
|
||||
${heading}
|
||||
</span>
|
||||
</ha-dialog-header>
|
||||
<div>
|
||||
${this._error
|
||||
@@ -344,6 +346,9 @@ class DialogImportBlueprint extends LitElement {
|
||||
ha-expansion-panel {
|
||||
--expansion-panel-content-padding: 0px;
|
||||
}
|
||||
.title-enlargeable {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class HaConfigSystemNavigation extends LitElement {
|
||||
const pages = configSections.general
|
||||
.filter((page) => canShowPage(this.hass, page))
|
||||
.map((page) => {
|
||||
let description = "";
|
||||
let description: string;
|
||||
|
||||
switch (page.translationKey) {
|
||||
case "backup":
|
||||
|
||||
@@ -637,7 +637,7 @@ class HaPanelDevAction extends LitElement {
|
||||
const example = {};
|
||||
fields.forEach((field) => {
|
||||
if (field.example) {
|
||||
let value: any = "";
|
||||
let value: any;
|
||||
try {
|
||||
value = load(field.example, { schema: JSON_SCHEMA });
|
||||
} catch (_err: any) {
|
||||
|
||||
@@ -267,8 +267,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
slot="trigger"
|
||||
.label=${localize("ui.components.subpage-data-table.sort_by", {
|
||||
sortColumn: this._sortColumn
|
||||
? ` ${columns[this._sortColumn]?.title || columns[this._sortColumn]?.label}` ||
|
||||
""
|
||||
? ` ${columns[this._sortColumn]?.title || columns[this._sortColumn]?.label}`
|
||||
: "",
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -68,14 +68,18 @@ export function buildPowerExcludeList(
|
||||
sources.forEach((entry) => {
|
||||
if (entry.stat_rate) powerIds.push(entry.stat_rate);
|
||||
if (entry.power_config) {
|
||||
if (entry.power_config.stat_rate)
|
||||
if (entry.power_config.stat_rate) {
|
||||
powerIds.push(entry.power_config.stat_rate);
|
||||
if (entry.power_config.stat_rate_inverted)
|
||||
}
|
||||
if (entry.power_config.stat_rate_inverted) {
|
||||
powerIds.push(entry.power_config.stat_rate_inverted);
|
||||
if (entry.power_config.stat_rate_from)
|
||||
}
|
||||
if (entry.power_config.stat_rate_from) {
|
||||
powerIds.push(entry.power_config.stat_rate_from);
|
||||
if (entry.power_config.stat_rate_to)
|
||||
}
|
||||
if (entry.power_config.stat_rate_to) {
|
||||
powerIds.push(entry.power_config.stat_rate_to);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -421,7 +421,7 @@ class AddIntegrationDialog extends LitElement {
|
||||
return [];
|
||||
}
|
||||
// Get domains for this brand
|
||||
let domains: string[] = [];
|
||||
let domains: string[];
|
||||
if ("integrations" in integration && integration.integrations) {
|
||||
domains = Object.keys(integration.integrations);
|
||||
if (this._pickedBrand === "apple") {
|
||||
|
||||
@@ -47,10 +47,12 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
|
||||
return featuresToSort.sort((a, b) => {
|
||||
// Place frontend.winter_mode at the bottom
|
||||
if (a.domain === "frontend" && a.preview_feature === "winter_mode")
|
||||
if (a.domain === "frontend" && a.preview_feature === "winter_mode") {
|
||||
return 1;
|
||||
if (b.domain === "frontend" && b.preview_feature === "winter_mode")
|
||||
}
|
||||
if (b.domain === "frontend" && b.preview_feature === "winter_mode") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Sort everything else alphabetically
|
||||
return domainToName(localize, a.domain).localeCompare(
|
||||
|
||||
@@ -103,7 +103,7 @@ export default class HaScriptFieldEditor extends LitElement {
|
||||
"field"
|
||||
: slugify(value.name);
|
||||
if (this.excludeKeys.includes(key)) {
|
||||
let uniqueKey = key;
|
||||
let uniqueKey: string;
|
||||
let i = 2;
|
||||
do {
|
||||
uniqueKey = `${key}_${i}`;
|
||||
|
||||
@@ -54,6 +54,18 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
this.querySelector("iframe")?.classList.add("loaded");
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// The suspendWhenHidden disconnect timer in partial-panel-resolver
|
||||
// removes this element from the DOM after 5 minutes, which triggers
|
||||
// _cleanupPanel() and destroys our child panel. When the user returns,
|
||||
// the same element is re-appended but `update()` won't call _createPanel
|
||||
// again (the `panel` property reference hasn't changed). Rebuild here.
|
||||
if (!this._setProperties && !this.hasChildNodes() && this.panel) {
|
||||
this._createPanel(this.panel);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._cleanupPanel();
|
||||
|
||||
@@ -90,6 +90,21 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
// Only include if we have both grid import and export configured
|
||||
if (hasGrid && hasReturn) {
|
||||
const gridResultCard = {
|
||||
type: "energy-grid-balance",
|
||||
collection_key: collectionKey,
|
||||
};
|
||||
sidebarSection.cards!.push(gridResultCard);
|
||||
view.sections!.push({
|
||||
type: "grid",
|
||||
column_span: 1,
|
||||
visibility: [SMALL_SCREEN_CONDITION],
|
||||
cards: [gridResultCard],
|
||||
});
|
||||
}
|
||||
|
||||
// Only include if we have a grid source & return.
|
||||
if (hasReturn) {
|
||||
const card = {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import type { HaNavigationPicker } from "../../../components/ha-navigation-picker";
|
||||
import "../../../components/ha-navigation-picker";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { CustomShortcutItem } from "../../../data/frontend";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import { showEditShortcutDialog } from "../dialogs/show-dialog-edit-shortcut";
|
||||
import "./home-shortcut-list-item";
|
||||
|
||||
// Paths already covered by built-in summaries
|
||||
const SUMMARY_PANEL_PATHS = [
|
||||
"/home",
|
||||
"/light",
|
||||
"/climate",
|
||||
"/security",
|
||||
"/energy",
|
||||
"/maintenance",
|
||||
];
|
||||
|
||||
@customElement("home-custom-shortcuts-editor")
|
||||
export class HomeCustomShortcutsEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public shortcuts: CustomShortcutItem[] = [];
|
||||
|
||||
protected render() {
|
||||
const excludePaths = [
|
||||
...SUMMARY_PANEL_PATHS,
|
||||
...this.shortcuts.map((s) => s.path),
|
||||
];
|
||||
|
||||
return html`
|
||||
<ha-sortable handle-selector=".handle" @item-moved=${this._shortcutMoved}>
|
||||
<div class="home-list">
|
||||
${repeat(
|
||||
this.shortcuts,
|
||||
(item) => item.path,
|
||||
(item, index) => html`
|
||||
<div class="home-list-item shortcut-row">
|
||||
<div class="handle">
|
||||
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
|
||||
</div>
|
||||
<home-shortcut-list-item
|
||||
class="shortcut-content"
|
||||
.hass=${this.hass}
|
||||
.item=${item}
|
||||
.index=${index}
|
||||
@edit-shortcut=${this._editShortcut}
|
||||
@delete-shortcut=${this._removeShortcut}
|
||||
></home-shortcut-list-item>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<ha-navigation-picker
|
||||
.hass=${this.hass}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.panel.home.editor.add_custom_shortcut"
|
||||
)}
|
||||
.excludePaths=${excludePaths}
|
||||
@value-changed=${this._addShortcut}
|
||||
></ha-navigation-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _update(next: CustomShortcutItem[]): void {
|
||||
fireEvent(this, "value-changed", { value: next });
|
||||
}
|
||||
|
||||
private _addShortcut(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const path = ev.detail.value;
|
||||
if (!path) return;
|
||||
|
||||
(ev.currentTarget as HaNavigationPicker).value = "";
|
||||
|
||||
if (this.shortcuts.some((item) => item.path === path)) return;
|
||||
|
||||
this._update([...this.shortcuts, { path }]);
|
||||
}
|
||||
|
||||
private _editShortcut(ev: HASSDomEvent<{ index: number }>): void {
|
||||
const { index } = ev.detail;
|
||||
const item = this.shortcuts[index];
|
||||
if (!item) return;
|
||||
|
||||
showEditShortcutDialog(this, {
|
||||
item,
|
||||
saveCallback: (updated) => {
|
||||
const next = [...this.shortcuts];
|
||||
next[index] = updated;
|
||||
this._update(next);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _removeShortcut(ev: HASSDomEvent<{ index: number }>): void {
|
||||
const { index } = ev.detail;
|
||||
const next = [...this.shortcuts];
|
||||
next.splice(index, 1);
|
||||
this._update(next);
|
||||
}
|
||||
|
||||
private _shortcutMoved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const next = [...this.shortcuts];
|
||||
const [moved] = next.splice(oldIndex, 1);
|
||||
next.splice(newIndex, 0, moved);
|
||||
this._update(next);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.home-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.shortcut-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.shortcut-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.handle {
|
||||
cursor: grab;
|
||||
color: var(--secondary-text-color);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
ha-navigation-picker {
|
||||
display: block;
|
||||
padding-top: var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"home-custom-shortcuts-editor": HomeCustomShortcutsEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { mdiDelete } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/entity/state-badge";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-settings-row";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"delete-favorite-entity": { index: number };
|
||||
}
|
||||
interface HTMLElementTagNameMap {
|
||||
"home-favorite-entity-list-item": HomeFavoriteEntityListItem;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("home-favorite-entity-list-item")
|
||||
export class HomeFavoriteEntityListItem extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: "entity-id" }) public entityId!: string;
|
||||
|
||||
@property({ type: Number }) public index = 0;
|
||||
|
||||
protected render() {
|
||||
const stateObj = this.hass.states[this.entityId];
|
||||
const { primary, secondary } = stateObj
|
||||
? computeEntityPickerDisplay(this.hass, stateObj)
|
||||
: { primary: this.entityId, secondary: undefined };
|
||||
|
||||
return html`
|
||||
<ha-settings-row slim>
|
||||
<state-badge
|
||||
slot="prefix"
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObj}
|
||||
></state-badge>
|
||||
<span slot="heading">${primary}</span>
|
||||
${secondary
|
||||
? html`<span slot="description">${secondary}</span>`
|
||||
: nothing}
|
||||
<ha-icon-button
|
||||
.path=${mdiDelete}
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
@click=${this._delete}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>
|
||||
`;
|
||||
}
|
||||
|
||||
private _delete() {
|
||||
fireEvent(this, "delete-favorite-entity", { index: this.index });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
ha-settings-row {
|
||||
padding: 0;
|
||||
gap: var(--ha-space-3);
|
||||
min-height: 40px;
|
||||
--settings-row-prefix-display: contents;
|
||||
--settings-row-content-display: contents;
|
||||
--settings-row-body-padding-top: var(--ha-space-1);
|
||||
--settings-row-body-padding-bottom: var(--ha-space-1);
|
||||
}
|
||||
state-badge {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
--state-icon-color: var(--secondary-text-color);
|
||||
}
|
||||
[slot="heading"],
|
||||
[slot="description"] {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import "./home-favorite-entity-list-item";
|
||||
|
||||
@customElement("home-favorites-editor")
|
||||
export class HomeFavoritesEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public favorites: string[] = [];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
${this.label ? html`<p class="field-label">${this.label}</p>` : nothing}
|
||||
${this.helper
|
||||
? html`<p class="field-helper">${this.helper}</p>`
|
||||
: nothing}
|
||||
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
|
||||
<div class="home-list">
|
||||
${repeat(
|
||||
this.favorites,
|
||||
(entityId) => entityId,
|
||||
(entityId, index) => html`
|
||||
<div class="home-list-item favorite-row">
|
||||
<div class="handle">
|
||||
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
|
||||
</div>
|
||||
<home-favorite-entity-list-item
|
||||
class="favorite-content"
|
||||
.hass=${this.hass}
|
||||
.entityId=${entityId}
|
||||
.index=${index}
|
||||
@delete-favorite-entity=${this._remove}
|
||||
></home-favorite-entity-list-item>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<ha-entity-picker
|
||||
add-button
|
||||
.hass=${this.hass}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
|
||||
)}
|
||||
.excludeEntities=${this.favorites}
|
||||
@value-changed=${this._add}
|
||||
></ha-entity-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _update(next: string[]): void {
|
||||
fireEvent(this, "value-changed", { value: next });
|
||||
}
|
||||
|
||||
private _add(ev: ValueChangedEvent<string | undefined>): void {
|
||||
ev.stopPropagation();
|
||||
const entityId = ev.detail.value;
|
||||
if (!entityId) return;
|
||||
|
||||
(ev.currentTarget as HaEntityPicker).value = "";
|
||||
|
||||
if (this.favorites.includes(entityId)) return;
|
||||
|
||||
this._update([...this.favorites, entityId]);
|
||||
}
|
||||
|
||||
private _remove(ev: HASSDomEvent<{ index: number }>): void {
|
||||
const { index } = ev.detail;
|
||||
const next = [...this.favorites];
|
||||
next.splice(index, 1);
|
||||
this._update(next);
|
||||
}
|
||||
|
||||
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const next = [...this.favorites];
|
||||
const [moved] = next.splice(oldIndex, 1);
|
||||
next.splice(newIndex, 0, moved);
|
||||
this._update(next);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.field-label {
|
||||
margin: 0 0 var(--ha-space-1) 0;
|
||||
font-size: 14px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.field-helper {
|
||||
margin: 0 0 var(--ha-space-2) 0;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
.home-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.favorite-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.favorite-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.handle {
|
||||
cursor: grab;
|
||||
color: var(--secondary-text-color);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
padding-top: var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"home-favorites-editor": HomeFavoritesEditor;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { CustomShortcutItem } from "../../../data/frontend";
|
||||
import { NavigationPathInfoController } from "../../../data/navigation-path-controller";
|
||||
@@ -55,14 +56,19 @@ export class HomeShortcutListItem extends LitElement {
|
||||
const iconStyle = { "--mdc-icon-size": "24px", color };
|
||||
|
||||
return html`
|
||||
${icon
|
||||
? html`<ha-icon .icon=${icon} style=${styleMap(iconStyle)}></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
.path=${iconPath}
|
||||
style=${styleMap(iconStyle)}
|
||||
></ha-svg-icon>`}
|
||||
<span class="label">${label}</span>
|
||||
<div class="actions">
|
||||
<ha-settings-row slim>
|
||||
${icon
|
||||
? html`<ha-icon
|
||||
slot="prefix"
|
||||
.icon=${icon}
|
||||
style=${styleMap(iconStyle)}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="prefix"
|
||||
.path=${iconPath}
|
||||
style=${styleMap(iconStyle)}
|
||||
></ha-svg-icon>`}
|
||||
<span slot="heading">${label}</span>
|
||||
<ha-icon-button
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize("ui.common.edit")}
|
||||
@@ -73,7 +79,7 @@ export class HomeShortcutListItem extends LitElement {
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
@click=${this._delete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
</ha-settings-row>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -87,27 +93,27 @@ export class HomeShortcutListItem extends LitElement {
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: block;
|
||||
}
|
||||
ha-settings-row {
|
||||
padding: 0;
|
||||
gap: var(--ha-space-3);
|
||||
min-height: 40px;
|
||||
--settings-row-prefix-display: contents;
|
||||
--settings-row-content-display: contents;
|
||||
--settings-row-body-padding-top: var(--ha-space-1);
|
||||
--settings-row-body-padding-bottom: var(--ha-space-1);
|
||||
}
|
||||
ha-icon,
|
||||
ha-svg-icon {
|
||||
--mdc-icon-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
[slot="heading"] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomTargetEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-switch";
|
||||
import {
|
||||
getSummaryLabel,
|
||||
HOME_SUMMARIES_ICONS,
|
||||
type HomeSummary,
|
||||
} from "../../lovelace/strategies/home/helpers/home-summaries";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
interface SummaryInfo {
|
||||
key: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Ordered to match dashboard rendering order
|
||||
const SUMMARY_ITEMS: SummaryInfo[] = [
|
||||
{ key: "light", icon: HOME_SUMMARIES_ICONS.light, color: "amber" },
|
||||
{ key: "climate", icon: HOME_SUMMARIES_ICONS.climate, color: "deep-orange" },
|
||||
{ key: "security", icon: HOME_SUMMARIES_ICONS.security, color: "blue-grey" },
|
||||
{
|
||||
key: "media_players",
|
||||
icon: HOME_SUMMARIES_ICONS.media_players,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
key: "maintenance",
|
||||
icon: HOME_SUMMARIES_ICONS.maintenance,
|
||||
color: "orange",
|
||||
},
|
||||
{ key: "weather", icon: "mdi:weather-partly-cloudy", color: "teal" },
|
||||
{ key: "energy", icon: HOME_SUMMARIES_ICONS.energy, color: "amber" },
|
||||
];
|
||||
|
||||
@customElement("home-summaries-editor")
|
||||
export class HomeSummariesEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public hiddenSummaries: string[] = [];
|
||||
|
||||
protected render() {
|
||||
const hidden = new Set(this.hiddenSummaries);
|
||||
return html`
|
||||
<div class="home-list">
|
||||
${SUMMARY_ITEMS.map((item) => {
|
||||
const label = this._getSummaryLabel(item.key);
|
||||
const color = computeCssColor(item.color);
|
||||
return html`
|
||||
<ha-settings-row slim>
|
||||
<ha-icon
|
||||
slot="prefix"
|
||||
.icon=${item.icon}
|
||||
style=${styleMap({ "--mdc-icon-size": "24px", color })}
|
||||
></ha-icon>
|
||||
<span slot="heading">${label}</span>
|
||||
<ha-switch
|
||||
.checked=${!hidden.has(item.key)}
|
||||
.summary=${item.key}
|
||||
@change=${this._toggleChanged}
|
||||
></ha-switch>
|
||||
</ha-settings-row>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getSummaryLabel(key: string): string {
|
||||
if (key === "weather") {
|
||||
return this.hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.summary_list.weather"
|
||||
);
|
||||
}
|
||||
return getSummaryLabel(this.hass.localize, key as HomeSummary);
|
||||
}
|
||||
|
||||
private _toggleChanged(
|
||||
ev: HASSDomTargetEvent<
|
||||
HTMLElement & {
|
||||
checked: boolean;
|
||||
summary: string;
|
||||
}
|
||||
>
|
||||
): void {
|
||||
const target = ev.target;
|
||||
const hidden = new Set(this.hiddenSummaries);
|
||||
if (target.checked) {
|
||||
hidden.delete(target.summary);
|
||||
} else {
|
||||
hidden.add(target.summary);
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: [...hidden] });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.home-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
ha-settings-row {
|
||||
padding: 0;
|
||||
gap: var(--ha-space-3);
|
||||
min-height: 40px;
|
||||
--settings-row-prefix-display: contents;
|
||||
--settings-row-content-display: contents;
|
||||
--settings-row-body-padding-top: var(--ha-space-1);
|
||||
--settings-row-body-padding-bottom: var(--ha-space-1);
|
||||
}
|
||||
[slot="heading"] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"home-summaries-editor": HomeSummariesEditor;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +1,42 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/entity/ha-entities-picker";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-expansion-panel";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-expansion-panel";
|
||||
import "../../../components/ha-form/ha-form";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-navigation-picker";
|
||||
import "../../../components/ha-switch";
|
||||
import type { HaFormSchema } from "../../../components/ha-form/types";
|
||||
import type {
|
||||
CustomShortcutItem,
|
||||
HomeFrontendSystemData,
|
||||
} from "../../../data/frontend";
|
||||
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import {
|
||||
getSummaryLabel,
|
||||
HOME_SUMMARIES_ICONS,
|
||||
type HomeSummary,
|
||||
} from "../../lovelace/strategies/home/helpers/home-summaries";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../components/home-shortcut-list-item";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import "../components/home-custom-shortcuts-editor";
|
||||
import "../components/home-favorites-editor";
|
||||
import "../components/home-summaries-editor";
|
||||
import type { EditHomeDialogParams } from "./show-dialog-edit-home";
|
||||
import { showEditShortcutDialog } from "./show-dialog-edit-shortcut";
|
||||
|
||||
interface SummaryInfo {
|
||||
key: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
interface EditorState {
|
||||
favorite_entities: string[];
|
||||
show_suggested_entities: boolean;
|
||||
show_welcome_message: boolean;
|
||||
hidden_summaries: string[];
|
||||
custom_shortcuts: CustomShortcutItem[];
|
||||
}
|
||||
|
||||
const SUGGESTED_ENTITIES_SCHEMA: HaFormSchema[] = [
|
||||
{ name: "show_suggested", selector: { boolean: {} } },
|
||||
];
|
||||
// The common-controls strategy caps the section at 8 (or the favorites count,
|
||||
// whichever is larger); once favorites reach the cap, predictions never render
|
||||
// so the suggested-entities toggle has no effect.
|
||||
const SUGGESTED_ENTITIES_CAP = 8;
|
||||
|
||||
// Ordered to match dashboard rendering order
|
||||
const SUMMARY_ITEMS: SummaryInfo[] = [
|
||||
{ key: "light", icon: HOME_SUMMARIES_ICONS.light, color: "amber" },
|
||||
{ key: "climate", icon: HOME_SUMMARIES_ICONS.climate, color: "deep-orange" },
|
||||
{
|
||||
key: "security",
|
||||
icon: HOME_SUMMARIES_ICONS.security,
|
||||
color: "blue-grey",
|
||||
},
|
||||
{
|
||||
key: "media_players",
|
||||
icon: HOME_SUMMARIES_ICONS.media_players,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
key: "maintenance",
|
||||
icon: HOME_SUMMARIES_ICONS.maintenance,
|
||||
color: "orange",
|
||||
},
|
||||
{ key: "weather", icon: "mdi:weather-partly-cloudy", color: "teal" },
|
||||
{ key: "energy", icon: HOME_SUMMARIES_ICONS.energy, color: "amber" },
|
||||
];
|
||||
|
||||
// Paths already covered by built-in summaries
|
||||
const SUMMARY_PANEL_PATHS = [
|
||||
"/home",
|
||||
"/light",
|
||||
"/climate",
|
||||
"/security",
|
||||
"/energy",
|
||||
"/maintenance",
|
||||
];
|
||||
|
||||
const WELCOME_MESSAGE_SCHEMA = [
|
||||
{ name: "welcome_message", selector: { boolean: {} } },
|
||||
const WELCOME_SCHEMA: HaFormSchema[] = [
|
||||
{ name: "show_welcome_message", selector: { boolean: {} } },
|
||||
];
|
||||
|
||||
@customElement("dialog-edit-home")
|
||||
@@ -86,7 +48,7 @@ export class DialogEditHome
|
||||
|
||||
@state() private _params?: EditHomeDialogParams;
|
||||
|
||||
@state() private _config?: HomeFrontendSystemData;
|
||||
@state() private _state?: EditorState;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@@ -94,7 +56,19 @@ export class DialogEditHome
|
||||
|
||||
public showDialog(params: EditHomeDialogParams): void {
|
||||
this._params = params;
|
||||
this._config = { ...params.config };
|
||||
this._state = {
|
||||
favorite_entities: params.config.favorite_entities
|
||||
? [...params.config.favorite_entities]
|
||||
: [],
|
||||
show_suggested_entities: !params.config.hide_suggested_entities,
|
||||
show_welcome_message: !params.config.hide_welcome_message,
|
||||
hidden_summaries: params.config.hidden_summaries
|
||||
? [...params.config.hidden_summaries]
|
||||
: [],
|
||||
custom_shortcuts: params.config.custom_shortcuts
|
||||
? [...params.config.custom_shortcuts]
|
||||
: [],
|
||||
};
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
@@ -105,23 +79,16 @@ export class DialogEditHome
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._config = undefined;
|
||||
this._state = undefined;
|
||||
this._submitting = false;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
if (!this._params || !this._state) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const hiddenSummaries = new Set(this._config?.hidden_summaries || []);
|
||||
const customShortcuts = this._config?.custom_shortcuts || [];
|
||||
const excludePaths = [
|
||||
...SUMMARY_PANEL_PATHS,
|
||||
...customShortcuts.map((s) => s.path),
|
||||
];
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.hass=${this.hass}
|
||||
@@ -146,115 +113,86 @@ export class DialogEditHome
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
expanded
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.favorite_entities"
|
||||
.header=${this.hass.localize("ui.panel.home.editor.personalize")}
|
||||
.secondary=${this.hass.localize(
|
||||
"ui.panel.home.editor.personalize_description"
|
||||
)}
|
||||
>
|
||||
<ha-icon slot="leading-icon" icon="mdi:star-outline"></ha-icon>
|
||||
<ha-icon slot="leading-icon" icon="mdi:palette-outline"></ha-icon>
|
||||
<div class="expansion-content">
|
||||
<p class="section-description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.home.editor.favorite_entities_description"
|
||||
)}
|
||||
</p>
|
||||
<ha-entities-picker
|
||||
autofocus
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.value=${this._config?.favorite_entities || []}
|
||||
.placeholder=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
|
||||
.data=${{
|
||||
show_welcome_message: this._state.show_welcome_message,
|
||||
}}
|
||||
.schema=${WELCOME_SCHEMA}
|
||||
.computeLabel=${this._computeWelcomeLabel}
|
||||
.computeHelper=${this._computeWelcomeHelper}
|
||||
@value-changed=${this._welcomeChanged}
|
||||
></ha-form>
|
||||
|
||||
<home-favorites-editor
|
||||
.hass=${this.hass}
|
||||
.favorites=${this._state.favorite_entities}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.favorite_entities"
|
||||
)}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.home.editor.favorite_entities_helper"
|
||||
)}
|
||||
reorder
|
||||
@value-changed=${this._favoriteEntitiesChanged}
|
||||
></ha-entities-picker>
|
||||
></home-favorites-editor>
|
||||
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{
|
||||
show_suggested: !this._config?.hide_suggested_entities,
|
||||
show_suggested_entities: this._state.show_suggested_entities,
|
||||
}}
|
||||
.schema=${SUGGESTED_ENTITIES_SCHEMA}
|
||||
.schema=${this._suggestedSchema(
|
||||
this._state.favorite_entities.length >= SUGGESTED_ENTITIES_CAP
|
||||
)}
|
||||
.computeLabel=${this._computeSuggestedLabel}
|
||||
.computeHelper=${this._computeSuggestedHelper}
|
||||
@value-changed=${this._suggestedEntitiesChanged}
|
||||
@value-changed=${this._suggestedChanged}
|
||||
></ha-form>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
|
||||
<h3 class="section-header">
|
||||
${this.hass.localize("ui.panel.home.editor.welcome_message")}
|
||||
</h3>
|
||||
<p class="section-description">
|
||||
${this.hass.localize("ui.panel.home.editor.welcome_message_helper")}
|
||||
</p>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{ welcome_message: !this._config?.hide_welcome_message }}
|
||||
.schema=${WELCOME_MESSAGE_SCHEMA}
|
||||
.computeLabel=${this._computeWelcomeLabel}
|
||||
@value-changed=${this._welcomeMessageToggleChanged}
|
||||
></ha-form>
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
expanded
|
||||
.header=${this.hass.localize("ui.panel.home.editor.summaries")}
|
||||
.secondary=${this.hass.localize(
|
||||
"ui.panel.home.editor.summaries_description"
|
||||
)}
|
||||
>
|
||||
<ha-icon
|
||||
slot="leading-icon"
|
||||
icon="mdi:view-dashboard-outline"
|
||||
></ha-icon>
|
||||
<div class="expansion-content">
|
||||
<home-summaries-editor
|
||||
.hass=${this.hass}
|
||||
.hiddenSummaries=${this._state.hidden_summaries}
|
||||
@value-changed=${this._hiddenSummariesChanged}
|
||||
></home-summaries-editor>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
|
||||
<h3 class="section-header">
|
||||
${this.hass.localize("ui.panel.home.editor.summaries")}
|
||||
</h3>
|
||||
<p class="section-description">
|
||||
${this.hass.localize("ui.panel.home.editor.summaries_description")}
|
||||
</p>
|
||||
<div class="home-list">
|
||||
${SUMMARY_ITEMS.map((item) => {
|
||||
const label = this._getSummaryLabel(item.key);
|
||||
const color = computeCssColor(item.color);
|
||||
return html`
|
||||
<label class="home-list-item summary-toggle">
|
||||
<ha-icon
|
||||
.icon=${item.icon}
|
||||
style=${styleMap({ "--mdc-icon-size": "24px", color })}
|
||||
></ha-icon>
|
||||
<span class="label">${label}</span>
|
||||
<ha-switch
|
||||
.checked=${!hiddenSummaries.has(item.key)}
|
||||
.summary=${item.key}
|
||||
@change=${this._summaryToggleChanged}
|
||||
></ha-switch>
|
||||
</label>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h3 class="section-header">
|
||||
${this.hass.localize("ui.panel.home.editor.custom_shortcuts")}
|
||||
</h3>
|
||||
<p class="section-description">
|
||||
${this.hass.localize(
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
expanded
|
||||
.header=${this.hass.localize("ui.panel.home.editor.custom_shortcuts")}
|
||||
.secondary=${this.hass.localize(
|
||||
"ui.panel.home.editor.custom_shortcuts_description"
|
||||
)}
|
||||
</p>
|
||||
<div class="home-list">
|
||||
${customShortcuts.map(
|
||||
(item, index) => html`
|
||||
<home-shortcut-list-item
|
||||
class="home-list-item"
|
||||
.hass=${this.hass}
|
||||
.item=${item}
|
||||
.index=${index}
|
||||
@edit-shortcut=${this._editShortcut}
|
||||
@delete-shortcut=${this._removeShortcut}
|
||||
></home-shortcut-list-item>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<ha-navigation-picker
|
||||
.hass=${this.hass}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.panel.home.editor.add_custom_shortcut"
|
||||
)}
|
||||
.excludePaths=${excludePaths}
|
||||
@value-changed=${this._addShortcut}
|
||||
></ha-navigation-picker>
|
||||
>
|
||||
<ha-icon slot="leading-icon" icon="mdi:link-variant"></ha-icon>
|
||||
<div class="expansion-content">
|
||||
<home-custom-shortcuts-editor
|
||||
.hass=${this.hass}
|
||||
.shortcuts=${this._state.custom_shortcuts}
|
||||
@value-changed=${this._shortcutsChanged}
|
||||
></home-custom-shortcuts-editor>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
@@ -277,132 +215,104 @@ export class DialogEditHome
|
||||
`;
|
||||
}
|
||||
|
||||
private _getSummaryLabel(key: string): string {
|
||||
if (key === "weather") {
|
||||
return this.hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.summary_list.weather"
|
||||
);
|
||||
}
|
||||
return getSummaryLabel(this.hass.localize, key as HomeSummary);
|
||||
}
|
||||
private _suggestedSchema = memoizeOne(
|
||||
(disabled: boolean) =>
|
||||
[
|
||||
{
|
||||
name: "show_suggested_entities",
|
||||
selector: { boolean: {} },
|
||||
disabled,
|
||||
},
|
||||
] as HaFormSchema[]
|
||||
);
|
||||
|
||||
private _summaryToggleChanged(ev: Event): void {
|
||||
const target = ev.target as HTMLElement & {
|
||||
checked: boolean;
|
||||
summary: string;
|
||||
};
|
||||
const summary = target.summary;
|
||||
const checked = target.checked;
|
||||
|
||||
const hiddenSummaries = new Set(this._config?.hidden_summaries || []);
|
||||
|
||||
if (checked) {
|
||||
hiddenSummaries.delete(summary);
|
||||
} else {
|
||||
hiddenSummaries.add(summary);
|
||||
}
|
||||
|
||||
this._config = {
|
||||
...this._config,
|
||||
hidden_summaries:
|
||||
hiddenSummaries.size > 0 ? [...hiddenSummaries] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private _computeWelcomeLabel = () =>
|
||||
private _computeWelcomeLabel = (): string =>
|
||||
this.hass.localize("ui.panel.home.editor.welcome_message");
|
||||
|
||||
private _welcomeMessageToggleChanged(ev: CustomEvent): void {
|
||||
this._config = {
|
||||
...this._config,
|
||||
hide_welcome_message: ev.detail.value.welcome_message ? undefined : true,
|
||||
};
|
||||
}
|
||||
private _computeWelcomeHelper = (): string =>
|
||||
this.hass.localize("ui.panel.home.editor.welcome_message_helper");
|
||||
|
||||
private _computeSuggestedLabel = (): string =>
|
||||
this.hass.localize("ui.panel.home.editor.suggested_entities");
|
||||
|
||||
private _computeSuggestedHelper = (): string =>
|
||||
this.hass.localize("ui.panel.home.editor.suggested_entities_description");
|
||||
|
||||
private _suggestedEntitiesChanged(ev: CustomEvent): void {
|
||||
const showSuggested = (ev.detail.value as { show_suggested: boolean })
|
||||
.show_suggested;
|
||||
this._config = {
|
||||
...this._config,
|
||||
hide_suggested_entities: showSuggested ? undefined : true,
|
||||
};
|
||||
}
|
||||
|
||||
private _updateShortcuts(
|
||||
updater: (shortcuts: CustomShortcutItem[]) => CustomShortcutItem[]
|
||||
): void {
|
||||
const next = updater([...(this._config?.custom_shortcuts || [])]);
|
||||
this._config = {
|
||||
...this._config,
|
||||
custom_shortcuts: next.length > 0 ? next : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private _addShortcut(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
const path = ev.detail.value as string;
|
||||
if (!path) return;
|
||||
|
||||
(ev.currentTarget as any).value = "";
|
||||
|
||||
this._updateShortcuts((shortcuts) =>
|
||||
shortcuts.some((item) => item.path === path)
|
||||
? shortcuts
|
||||
: [...shortcuts, { path }]
|
||||
private _computeSuggestedHelper = (): string => {
|
||||
const favoritesFull =
|
||||
(this._state?.favorite_entities.length ?? 0) >= SUGGESTED_ENTITIES_CAP;
|
||||
return this.hass.localize(
|
||||
favoritesFull
|
||||
? "ui.panel.home.editor.suggested_entities_disabled_description"
|
||||
: "ui.panel.home.editor.suggested_entities_description"
|
||||
);
|
||||
};
|
||||
|
||||
private _favoriteEntitiesChanged(ev: ValueChangedEvent<string[]>): void {
|
||||
this._state = {
|
||||
...this._state!,
|
||||
favorite_entities: ev.detail.value,
|
||||
};
|
||||
}
|
||||
|
||||
private _editShortcut(ev: HASSDomEvent<{ index: number }>): void {
|
||||
const { index } = ev.detail;
|
||||
const item = this._config?.custom_shortcuts?.[index];
|
||||
if (!item) return;
|
||||
|
||||
showEditShortcutDialog(this, {
|
||||
item,
|
||||
saveCallback: (updated) => {
|
||||
this._updateShortcuts((shortcuts) => {
|
||||
shortcuts[index] = updated;
|
||||
return shortcuts;
|
||||
});
|
||||
},
|
||||
});
|
||||
private _welcomeChanged(
|
||||
ev: ValueChangedEvent<{ show_welcome_message: boolean }>
|
||||
): void {
|
||||
this._state = {
|
||||
...this._state!,
|
||||
show_welcome_message: ev.detail.value.show_welcome_message,
|
||||
};
|
||||
}
|
||||
|
||||
private _removeShortcut(ev: HASSDomEvent<{ index: number }>): void {
|
||||
const { index } = ev.detail;
|
||||
this._updateShortcuts((shortcuts) => {
|
||||
shortcuts.splice(index, 1);
|
||||
return shortcuts;
|
||||
});
|
||||
private _suggestedChanged(
|
||||
ev: ValueChangedEvent<{ show_suggested_entities: boolean }>
|
||||
): void {
|
||||
this._state = {
|
||||
...this._state!,
|
||||
show_suggested_entities: ev.detail.value.show_suggested_entities,
|
||||
};
|
||||
}
|
||||
|
||||
private _favoriteEntitiesChanged(ev: CustomEvent): void {
|
||||
const entities = ev.detail.value as string[];
|
||||
this._config = {
|
||||
...this._config,
|
||||
favorite_entities: entities.length > 0 ? entities : undefined,
|
||||
private _hiddenSummariesChanged(ev: ValueChangedEvent<string[]>): void {
|
||||
this._state = {
|
||||
...this._state!,
|
||||
hidden_summaries: ev.detail.value,
|
||||
};
|
||||
}
|
||||
|
||||
private _shortcutsChanged(ev: ValueChangedEvent<CustomShortcutItem[]>): void {
|
||||
this._state = {
|
||||
...this._state!,
|
||||
custom_shortcuts: ev.detail.value,
|
||||
};
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._params || !this._config) {
|
||||
return;
|
||||
}
|
||||
if (!this._params || !this._state) return;
|
||||
|
||||
this._submitting = true;
|
||||
const editor = this._state;
|
||||
|
||||
const config: HomeFrontendSystemData = {
|
||||
...this._params.config,
|
||||
favorite_entities:
|
||||
editor.favorite_entities.length > 0
|
||||
? editor.favorite_entities
|
||||
: undefined,
|
||||
hide_suggested_entities: editor.show_suggested_entities
|
||||
? undefined
|
||||
: true,
|
||||
hide_welcome_message: editor.show_welcome_message ? undefined : true,
|
||||
hidden_summaries:
|
||||
editor.hidden_summaries.length > 0
|
||||
? editor.hidden_summaries
|
||||
: undefined,
|
||||
custom_shortcuts:
|
||||
editor.custom_shortcuts.length > 0
|
||||
? editor.custom_shortcuts
|
||||
: undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
await this._params.saveConfig(this._config);
|
||||
await this._params.saveConfig(config);
|
||||
this.closeDialog();
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to save home configuration:", err);
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
}
|
||||
@@ -415,60 +325,35 @@ export class DialogEditHome
|
||||
--dialog-content-padding: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin: var(--ha-space-6) 0 var(--ha-space-1) 0;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 0 0 var(--ha-space-2) 0;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.home-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
padding: var(--ha-space-2) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.summary-toggle .label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
--expansion-panel-content-padding: 0;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
--ha-card-border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
|
||||
ha-expansion-panel + ha-expansion-panel {
|
||||
margin-top: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.expansion-content {
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-navigation-picker {
|
||||
ha-form {
|
||||
display: block;
|
||||
padding-top: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-entities-picker {
|
||||
home-favorites-editor {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-2);
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin: 0 calc(-1 * var(--dialog-content-padding));
|
||||
margin: calc(-1 * var(--dialog-content-padding));
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -106,7 +106,7 @@ export class HuiPowerTotalBadge
|
||||
|
||||
const power = this._computeTotalPower(this._data.prefs);
|
||||
|
||||
let displayValue = "";
|
||||
let displayValue: string;
|
||||
if (power >= 1000) {
|
||||
displayValue = `${formatNumber(power / 1000, this.hass.locale, {
|
||||
maximumFractionDigits: 2,
|
||||
|
||||
@@ -6,6 +6,7 @@ import "../../../components/ha-svg-icon";
|
||||
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import { getConfigEntityId } from "../common/get-config-entity-id";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { createBadgeElement } from "../create-element/create-badge-element";
|
||||
import { createErrorBadgeConfig } from "../create-element/create-element-base";
|
||||
@@ -101,6 +102,13 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (changedProps.has("config")) {
|
||||
this._conditionContext = {
|
||||
...this._conditionContext,
|
||||
entity_id: this.config ? getConfigEntityId(this.config) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (!this._element) {
|
||||
this.load();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing, svg } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
@@ -19,7 +20,11 @@ import {
|
||||
internationalizationContext,
|
||||
} from "../../../data/context";
|
||||
import type { ForecastAttribute, ForecastEvent } from "../../../data/weather";
|
||||
import { subscribeForecast, WeatherEntityFeature } from "../../../data/weather";
|
||||
import {
|
||||
getForecastPrecipitation,
|
||||
subscribeForecast,
|
||||
WeatherEntityFeature,
|
||||
} from "../../../data/weather";
|
||||
import type {
|
||||
HomeAssistantConnection,
|
||||
HomeAssistantInternationalization,
|
||||
@@ -32,7 +37,7 @@ import type {
|
||||
|
||||
export const DEFAULT_DAYS_TO_SHOW = 7;
|
||||
|
||||
const MAX_BAR_WIDTH = 12;
|
||||
const MAX_BAR_WIDTH = 8;
|
||||
|
||||
export type DailyForecastType = "daily" | "twice_daily";
|
||||
|
||||
@@ -183,16 +188,20 @@ class HuiDailyForecastCardFeature
|
||||
`;
|
||||
}
|
||||
|
||||
const showTemperature = this._config.show_temperature ?? true;
|
||||
const showPrecipitation = this._config.show_precipitation ?? false;
|
||||
|
||||
const daysToShow = this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW;
|
||||
const entriesPerDay = this._subscribedType === "twice_daily" ? 2 : 1;
|
||||
const entries = this._forecast
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.temperature != null &&
|
||||
!Number.isNaN(entry.temperature) &&
|
||||
entry.templow != null &&
|
||||
!Number.isNaN(entry.templow)
|
||||
)
|
||||
.filter((entry) => {
|
||||
if (showTemperature) {
|
||||
return (
|
||||
Number.isFinite(entry.temperature) && Number.isFinite(entry.templow)
|
||||
);
|
||||
}
|
||||
return showPrecipitation;
|
||||
})
|
||||
.slice(0, daysToShow * entriesPerDay);
|
||||
|
||||
if (!entries.length) {
|
||||
@@ -217,62 +226,152 @@ class HuiDailyForecastCardFeature
|
||||
const minGap = 4;
|
||||
const slotWidth = width / entries.length;
|
||||
const barWidth = Math.max(1, Math.min(MAX_BAR_WIDTH, slotWidth - minGap));
|
||||
const drawableHeight = height - padding * 2;
|
||||
|
||||
const showTemperature = this._config!.show_temperature ?? true;
|
||||
const showCurrentTemperature =
|
||||
this._config!.show_current_temperature ?? true;
|
||||
const showPrecipitation = this._config!.show_precipitation ?? false;
|
||||
const precipitationType = this._config!.precipitation_type ?? "amount";
|
||||
const customColor = this._config!.color
|
||||
? computeCssColor(this._config!.color)
|
||||
: undefined;
|
||||
|
||||
const currentTemp = Number(this._stateObj?.attributes?.temperature);
|
||||
const hasCurrentTemp = currentTemp != null && !Number.isNaN(currentTemp);
|
||||
const hasCurrentTemp = Number.isFinite(currentTemp);
|
||||
|
||||
let tempMin = Infinity;
|
||||
let tempMax = -Infinity;
|
||||
for (const entry of entries) {
|
||||
tempMin = Math.min(tempMin, entry.templow!);
|
||||
tempMax = Math.max(tempMax, entry.temperature);
|
||||
}
|
||||
if (hasCurrentTemp) {
|
||||
tempMin = Math.min(tempMin, currentTemp);
|
||||
tempMax = Math.max(tempMax, currentTemp);
|
||||
}
|
||||
if (tempMin === tempMax) {
|
||||
tempMin -= 1;
|
||||
tempMax += 1;
|
||||
let yFor: ((value: number) => number) | undefined;
|
||||
if (showTemperature) {
|
||||
let tempMin = Infinity;
|
||||
let tempMax = -Infinity;
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
Number.isFinite(entry.templow) &&
|
||||
Number.isFinite(entry.temperature)
|
||||
) {
|
||||
tempMin = Math.min(tempMin, entry.templow!);
|
||||
tempMax = Math.max(tempMax, entry.temperature);
|
||||
}
|
||||
}
|
||||
if (hasCurrentTemp) {
|
||||
tempMin = Math.min(tempMin, currentTemp);
|
||||
tempMax = Math.max(tempMax, currentTemp);
|
||||
}
|
||||
if (tempMin === tempMax) {
|
||||
tempMin -= 1;
|
||||
tempMax += 1;
|
||||
}
|
||||
yFor = (value: number) =>
|
||||
padding +
|
||||
drawableHeight -
|
||||
((value - tempMin) / (tempMax - tempMin)) * drawableHeight;
|
||||
}
|
||||
|
||||
const drawableHeight = height - padding * 2;
|
||||
const yFor = (value: number) =>
|
||||
padding +
|
||||
drawableHeight -
|
||||
((value - tempMin) / (tempMax - tempMin)) * drawableHeight;
|
||||
let maxPrecipitation = 0;
|
||||
if (showPrecipitation) {
|
||||
if (precipitationType === "probability") {
|
||||
maxPrecipitation = 100;
|
||||
} else {
|
||||
for (const entry of entries) {
|
||||
const value = getForecastPrecipitation(entry, precipitationType);
|
||||
if (Number.isFinite(value)) {
|
||||
maxPrecipitation = Math.max(maxPrecipitation, value!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bars = entries.map((entry, i) => {
|
||||
const x = slotWidth * i + (slotWidth - barWidth) / 2;
|
||||
const yHigh = yFor(entry.temperature);
|
||||
const yLow = yFor(entry.templow!);
|
||||
const barHeight = Math.max(1, yLow - yHigh);
|
||||
const rx = Math.min(barWidth / 2, barHeight / 2);
|
||||
const fill = entry.condition
|
||||
? `var(--state-weather-${slugify(entry.condition, "_")}-color, var(--feature-color))`
|
||||
: "var(--feature-color)";
|
||||
return svg`<rect
|
||||
x=${x}
|
||||
y=${yHigh}
|
||||
width=${barWidth}
|
||||
height=${barHeight}
|
||||
rx=${rx}
|
||||
ry=${rx}
|
||||
fill=${fill}
|
||||
></rect>`;
|
||||
});
|
||||
const rainBarWidth = Math.max(
|
||||
barWidth,
|
||||
Math.min(barWidth + 8, slotWidth - 2)
|
||||
);
|
||||
|
||||
const currentTempLine = hasCurrentTemp
|
||||
? svg`<line
|
||||
x1="0"
|
||||
x2=${width}
|
||||
y1=${yFor(currentTemp)}
|
||||
y2=${yFor(currentTemp)}
|
||||
stroke="var(--feature-color)"
|
||||
stroke-width="1"
|
||||
stroke-opacity="0.5"
|
||||
vector-effect="non-scaling-stroke"
|
||||
></line>`
|
||||
const precipitationBars =
|
||||
showPrecipitation && maxPrecipitation > 0
|
||||
? entries.map((entry, i) => {
|
||||
const value = getForecastPrecipitation(entry, precipitationType);
|
||||
if (!Number.isFinite(value) || value! <= 0) {
|
||||
return nothing;
|
||||
}
|
||||
const x = slotWidth * i + (slotWidth - rainBarWidth) / 2;
|
||||
const barHeight = Math.max(
|
||||
1,
|
||||
(value! / maxPrecipitation) * drawableHeight
|
||||
);
|
||||
const y = padding + drawableHeight - barHeight;
|
||||
return svg`<rect
|
||||
x=${x}
|
||||
y=${y}
|
||||
width=${rainBarWidth}
|
||||
height=${barHeight}
|
||||
fill="var(--state-weather-rainy-color)"
|
||||
opacity="0.4"
|
||||
></rect>`;
|
||||
})
|
||||
: nothing;
|
||||
|
||||
const bars =
|
||||
showTemperature && yFor
|
||||
? entries.map((entry, i) => {
|
||||
if (
|
||||
!Number.isFinite(entry.temperature) ||
|
||||
!Number.isFinite(entry.templow)
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
const x = slotWidth * i + (slotWidth - barWidth) / 2;
|
||||
const yHigh = yFor(entry.temperature);
|
||||
const yLow = yFor(entry.templow!);
|
||||
const barHeight = Math.max(1, yLow - yHigh);
|
||||
const rx = Math.min(barWidth / 2, barHeight / 2);
|
||||
const fill =
|
||||
customColor ??
|
||||
(entry.condition
|
||||
? `var(--state-weather-${slugify(entry.condition, "_")}-color, var(--feature-color))`
|
||||
: "var(--feature-color)");
|
||||
return svg`<rect
|
||||
x=${x}
|
||||
y=${yHigh}
|
||||
width=${barWidth}
|
||||
height=${barHeight}
|
||||
rx=${rx}
|
||||
ry=${rx}
|
||||
fill=${fill}
|
||||
></rect>`;
|
||||
})
|
||||
: nothing;
|
||||
|
||||
const currentTempLine =
|
||||
showTemperature && showCurrentTemperature && yFor && hasCurrentTemp
|
||||
? svg`<line
|
||||
x1="0"
|
||||
x2=${width}
|
||||
y1=${yFor(currentTemp)}
|
||||
y2=${yFor(currentTemp)}
|
||||
stroke=${customColor ?? "var(--feature-color)"}
|
||||
stroke-width="1"
|
||||
stroke-opacity="0.5"
|
||||
vector-effect="non-scaling-stroke"
|
||||
></line>`
|
||||
: nothing;
|
||||
|
||||
const dotRadius = 1.5;
|
||||
const dots = !showTemperature
|
||||
? entries.map((entry, i) => {
|
||||
const value = getForecastPrecipitation(entry, precipitationType);
|
||||
if (Number.isFinite(value) && value! > 0) {
|
||||
return nothing;
|
||||
}
|
||||
const cx = slotWidth * i + slotWidth / 2;
|
||||
const cy = padding + drawableHeight - dotRadius;
|
||||
return svg`<circle
|
||||
cx=${cx}
|
||||
cy=${cy}
|
||||
r=${dotRadius}
|
||||
fill="var(--state-weather-rainy-color)"
|
||||
opacity="0.4"
|
||||
></circle>`;
|
||||
})
|
||||
: nothing;
|
||||
|
||||
return html`
|
||||
@@ -282,7 +381,7 @@ class HuiDailyForecastCardFeature
|
||||
viewBox="0 0 ${width} ${height}"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
${bars}${currentTempLine}
|
||||
${dots}${precipitationBars}${bars}${currentTempLine}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing, svg } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import "../../../components/ha-spinner";
|
||||
import type { ForecastEvent } from "../../../data/weather";
|
||||
import { subscribeForecast, WeatherEntityFeature } from "../../../data/weather";
|
||||
import type { ForecastAttribute, ForecastEvent } from "../../../data/weather";
|
||||
import {
|
||||
getForecastPrecipitation,
|
||||
subscribeForecast,
|
||||
WeatherEntityFeature,
|
||||
} from "../../../data/weather";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { coordinates } from "../common/graph/coordinates";
|
||||
import "../components/hui-graph-base";
|
||||
@@ -18,6 +24,9 @@ import type {
|
||||
|
||||
export const DEFAULT_HOURS_TO_SHOW = 24;
|
||||
|
||||
const MS_PER_HOUR = 60 * 60 * 1000;
|
||||
const MAX_RAIN_BAR_WIDTH = 16;
|
||||
|
||||
export const supportsHourlyForecastCardFeature = (
|
||||
hass: HomeAssistant,
|
||||
context: LovelaceCardFeatureContext
|
||||
@@ -45,6 +54,8 @@ class HuiHourlyForecastCardFeature
|
||||
|
||||
@state() private _config?: HourlyForecastCardFeatureConfig;
|
||||
|
||||
@state() private _forecast?: ForecastAttribute[];
|
||||
|
||||
@state() private _coordinates?: [number, number][];
|
||||
|
||||
@state() private _yAxisOrigin?: number;
|
||||
@@ -117,14 +128,26 @@ class HuiHourlyForecastCardFeature
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (!this._coordinates) {
|
||||
if (!this._forecast || !this._coordinates) {
|
||||
return html`
|
||||
<div class="container loading">
|
||||
<ha-spinner size="small"></ha-spinner>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (!this._coordinates.length) {
|
||||
|
||||
const showTemperature = this._config.show_temperature ?? true;
|
||||
const showPrecipitation = this._config.show_precipitation ?? false;
|
||||
|
||||
const showDots = !showTemperature && showPrecipitation;
|
||||
const layer =
|
||||
showPrecipitation || showDots
|
||||
? this._renderForecastLayer(showPrecipitation, showDots)
|
||||
: nothing;
|
||||
const hasGraphData = this._coordinates.length > 0;
|
||||
const showGraph = showTemperature && hasGraphData;
|
||||
|
||||
if (!showGraph && layer === nothing) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="info">
|
||||
@@ -135,11 +158,141 @@ class HuiHourlyForecastCardFeature
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const customColor = this._config.color
|
||||
? computeCssColor(this._config.color)
|
||||
: undefined;
|
||||
const graphStyle = customColor
|
||||
? styleMap({ "--feature-color": customColor })
|
||||
: nothing;
|
||||
|
||||
return html`
|
||||
<hui-graph-base
|
||||
.coordinates=${this._coordinates}
|
||||
.yAxisOrigin=${this._yAxisOrigin}
|
||||
></hui-graph-base>
|
||||
<div class="layers">
|
||||
${layer}
|
||||
${showGraph
|
||||
? html`
|
||||
<hui-graph-base
|
||||
.coordinates=${this._coordinates}
|
||||
.yAxisOrigin=${this._yAxisOrigin}
|
||||
style=${graphStyle}
|
||||
></hui-graph-base>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderForecastLayer(showRain: boolean, showDots: boolean) {
|
||||
if (!this._forecast?.length) {
|
||||
return nothing;
|
||||
}
|
||||
const width = this.clientWidth || 300;
|
||||
const height = this.clientHeight || 42;
|
||||
// No bottom padding so bars and dots line up with the line graph baseline.
|
||||
const topPadding = 4;
|
||||
const drawableHeight = height - topPadding;
|
||||
|
||||
const now = Date.now();
|
||||
const hoursToShow = this._config!.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
|
||||
const maxTime =
|
||||
Math.floor((now + hoursToShow * MS_PER_HOUR) / MS_PER_HOUR) * MS_PER_HOUR;
|
||||
const timeRange = maxTime - now;
|
||||
if (timeRange <= 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const precipitationType = this._config!.precipitation_type ?? "amount";
|
||||
|
||||
const inRange: { entry: ForecastAttribute; t: number }[] = [];
|
||||
for (const entry of this._forecast) {
|
||||
const t = new Date(entry.datetime).getTime();
|
||||
if (t >= now && t <= maxTime) {
|
||||
inRange.push({ entry, t });
|
||||
}
|
||||
}
|
||||
|
||||
if (!inRange.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const rainRects: TemplateResult[] = [];
|
||||
if (showRain) {
|
||||
const rainEntries = inRange.filter(({ entry }) => {
|
||||
const value = getForecastPrecipitation(entry, precipitationType);
|
||||
return Number.isFinite(value) && value! > 0;
|
||||
});
|
||||
let maxPrecipitation = 0;
|
||||
if (precipitationType === "probability") {
|
||||
maxPrecipitation = 100;
|
||||
} else {
|
||||
for (const { entry } of rainEntries) {
|
||||
maxPrecipitation = Math.max(
|
||||
maxPrecipitation,
|
||||
getForecastPrecipitation(entry, precipitationType)!
|
||||
);
|
||||
}
|
||||
}
|
||||
if (maxPrecipitation > 0 && rainEntries.length) {
|
||||
const slotWidth = width / hoursToShow;
|
||||
const barWidth = Math.max(
|
||||
1,
|
||||
Math.min(MAX_RAIN_BAR_WIDTH, slotWidth - 2)
|
||||
);
|
||||
for (const { entry, t } of rainEntries) {
|
||||
const value = getForecastPrecipitation(entry, precipitationType)!;
|
||||
const xCenter = ((t - now) / timeRange) * width;
|
||||
const x = xCenter - barWidth / 2;
|
||||
const barHeight = Math.max(
|
||||
1,
|
||||
(value / maxPrecipitation) * drawableHeight
|
||||
);
|
||||
const y = height - barHeight;
|
||||
rainRects.push(svg`<rect
|
||||
x=${x}
|
||||
y=${y}
|
||||
width=${barWidth}
|
||||
height=${barHeight}
|
||||
fill="var(--state-weather-rainy-color)"
|
||||
opacity="0.4"
|
||||
></rect>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dots: TemplateResult[] = [];
|
||||
if (showDots) {
|
||||
const dotRadius = 1.5;
|
||||
const cy = height - dotRadius;
|
||||
for (const { entry, t } of inRange) {
|
||||
const value = getForecastPrecipitation(entry, precipitationType);
|
||||
if (Number.isFinite(value) && value! > 0) {
|
||||
continue;
|
||||
}
|
||||
const cx = ((t - now) / timeRange) * width;
|
||||
dots.push(svg`<circle
|
||||
cx=${cx}
|
||||
cy=${cy}
|
||||
r=${dotRadius}
|
||||
fill="var(--state-weather-rainy-color)"
|
||||
opacity="0.4"
|
||||
></circle>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rainRects.length && !dots.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<svg
|
||||
class="rain"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 ${width} ${height}"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
${dots}${rainRects}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -162,10 +315,9 @@ class HuiHourlyForecastCardFeature
|
||||
const data: [number, number][] = [];
|
||||
const now = Date.now();
|
||||
const hoursToShow = this._config!.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
|
||||
const msPerHour = 60 * 60 * 1000;
|
||||
// Round down to the nearest hour so the axis aligns with forecast data points
|
||||
const maxTime =
|
||||
Math.floor((now + hoursToShow * msPerHour) / msPerHour) * msPerHour;
|
||||
Math.floor((now + hoursToShow * MS_PER_HOUR) / MS_PER_HOUR) * MS_PER_HOUR;
|
||||
|
||||
// Start with current temperature
|
||||
const currentTemp = stateObj?.attributes?.temperature;
|
||||
@@ -218,6 +370,7 @@ class HuiHourlyForecastCardFeature
|
||||
entityId,
|
||||
"hourly",
|
||||
(forecastEvent) => {
|
||||
this._forecast = forecastEvent.forecast ?? [];
|
||||
this._computeCoordinates(forecastEvent);
|
||||
}
|
||||
).catch((err) => {
|
||||
@@ -234,24 +387,45 @@ class HuiHourlyForecastCardFeature
|
||||
height: var(--feature-height);
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
align-items: stretch;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.container.loading {
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
hui-graph-base {
|
||||
.info {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
.layers {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
--accent-color: var(--feature-color);
|
||||
height: 100%;
|
||||
border-bottom-right-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
hui-graph-base {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
--accent-color: var(--feature-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
mdiPlay,
|
||||
mdiPlayPause,
|
||||
mdiPower,
|
||||
mdiPowerOff,
|
||||
mdiPowerOn,
|
||||
mdiSkipNext,
|
||||
mdiSkipPrevious,
|
||||
mdiStop,
|
||||
@@ -198,25 +200,32 @@ class HuiMediaPlayerPlaybackCardFeature
|
||||
stateObj: MediaPlayerEntity
|
||||
): ControlButton[] {
|
||||
const active = stateActive(stateObj);
|
||||
const assumedState = stateObj.attributes.assumed_state === true;
|
||||
const buttons: ControlButton[] = [];
|
||||
|
||||
for (const control of this._controls) {
|
||||
switch (control) {
|
||||
case "turn_off":
|
||||
if (
|
||||
active &&
|
||||
(active || assumedState) &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_OFF)
|
||||
) {
|
||||
buttons.push({ icon: mdiPower, action: "turn_off" });
|
||||
buttons.push({
|
||||
icon: assumedState ? mdiPowerOff : mdiPower,
|
||||
action: "turn_off",
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "turn_on":
|
||||
if (
|
||||
!active &&
|
||||
(!active || assumedState) &&
|
||||
!isUnavailableState(stateObj.state) &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_ON)
|
||||
) {
|
||||
buttons.push({ icon: mdiPower, action: "turn_on" });
|
||||
buttons.push({
|
||||
icon: assumedState ? mdiPowerOn : mdiPower,
|
||||
action: "turn_on",
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "media_play":
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { AlarmMode } from "../../../data/alarm_control_panel";
|
||||
import type { HvacMode } from "../../../data/climate";
|
||||
import type { OperationMode } from "../../../data/water_heater";
|
||||
import type { ForecastPrecipitationType } from "../../../data/weather";
|
||||
|
||||
export type { ForecastPrecipitationType };
|
||||
|
||||
export type ButtonCardData = Record<string, any>;
|
||||
|
||||
@@ -244,12 +247,21 @@ export interface TrendGraphCardFeatureConfig {
|
||||
export interface HourlyForecastCardFeatureConfig {
|
||||
type: "hourly-forecast";
|
||||
hours_to_show?: number;
|
||||
show_temperature?: boolean;
|
||||
show_precipitation?: boolean;
|
||||
precipitation_type?: ForecastPrecipitationType;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface DailyForecastCardFeatureConfig {
|
||||
type: "daily-forecast";
|
||||
forecast_type?: "daily" | "twice_daily";
|
||||
days_to_show?: number;
|
||||
show_temperature?: boolean;
|
||||
show_current_temperature?: boolean;
|
||||
show_precipitation?: boolean;
|
||||
precipitation_type?: ForecastPrecipitationType;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const AREA_CONTROL_DOMAINS = [
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { ClockCardConfig, ClockCardDatePart } from "../types";
|
||||
|
||||
type ClockCardSeparatorPart = Extract<
|
||||
ClockCardDatePart,
|
||||
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
|
||||
>;
|
||||
|
||||
type ClockCardValuePart = Exclude<ClockCardDatePart, ClockCardSeparatorPart>;
|
||||
|
||||
/**
|
||||
* Normalized date configuration used by clock card renderers.
|
||||
*/
|
||||
interface ClockCardDateConfig {
|
||||
parts: ClockCardDatePart[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the locale and time zone for a clock card from `hass` and the
|
||||
* card's configuration. Applies the optional `time_format` override to the
|
||||
* locale and falls back to the user's preferred time zone.
|
||||
*/
|
||||
export const resolveClockCardLocale = (
|
||||
hass: HomeAssistant,
|
||||
config: Pick<ClockCardConfig, "time_format" | "time_zone">
|
||||
): { locale: HomeAssistant["locale"]; timeZone: string } => {
|
||||
const locale = config.time_format
|
||||
? { ...hass.locale, time_format: config.time_format }
|
||||
: hass.locale;
|
||||
|
||||
const timeZone =
|
||||
config.time_zone ||
|
||||
resolveTimeZone(locale.time_zone, hass.config?.time_zone);
|
||||
|
||||
return { locale, timeZone };
|
||||
};
|
||||
|
||||
/**
|
||||
* All selectable date tokens exposed by the clock card editor.
|
||||
*/
|
||||
export const CLOCK_CARD_DATE_PARTS: readonly ClockCardDatePart[] = [
|
||||
"weekday-short",
|
||||
"weekday-long",
|
||||
"day-numeric",
|
||||
"day-2-digit",
|
||||
"month-short",
|
||||
"month-long",
|
||||
"month-numeric",
|
||||
"month-2-digit",
|
||||
"year-2-digit",
|
||||
"year-numeric",
|
||||
"separator-dash",
|
||||
"separator-slash",
|
||||
"separator-dot",
|
||||
"separator-new-line",
|
||||
];
|
||||
|
||||
const DATE_PART_OPTIONS: Record<
|
||||
ClockCardValuePart,
|
||||
Pick<Intl.DateTimeFormatOptions, "weekday" | "day" | "month" | "year">
|
||||
> = {
|
||||
"weekday-short": { weekday: "short" },
|
||||
"weekday-long": { weekday: "long" },
|
||||
"day-numeric": { day: "numeric" },
|
||||
"day-2-digit": { day: "2-digit" },
|
||||
"month-short": { month: "short" },
|
||||
"month-long": { month: "long" },
|
||||
"month-numeric": { month: "numeric" },
|
||||
"month-2-digit": { month: "2-digit" },
|
||||
"year-2-digit": { year: "2-digit" },
|
||||
"year-numeric": { year: "numeric" },
|
||||
};
|
||||
|
||||
const DATE_SEPARATORS: Record<ClockCardSeparatorPart, string> = {
|
||||
"separator-dash": "-",
|
||||
"separator-slash": "/",
|
||||
"separator-dot": ".",
|
||||
"separator-new-line": "\n",
|
||||
};
|
||||
|
||||
const DATE_SEPARATOR_PARTS = new Set<ClockCardSeparatorPart>([
|
||||
"separator-dash",
|
||||
"separator-slash",
|
||||
"separator-dot",
|
||||
"separator-new-line",
|
||||
]);
|
||||
|
||||
const DATE_PART_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
const isClockCardDatePart = (value: string): value is ClockCardDatePart =>
|
||||
CLOCK_CARD_DATE_PARTS.includes(value as ClockCardDatePart);
|
||||
|
||||
const isDateSeparatorPart = (
|
||||
part: ClockCardDatePart
|
||||
): part is ClockCardSeparatorPart =>
|
||||
DATE_SEPARATOR_PARTS.has(part as ClockCardSeparatorPart);
|
||||
|
||||
/**
|
||||
* Returns a reusable formatter for a specific date token.
|
||||
*/
|
||||
const getDatePartFormatter = (
|
||||
part: ClockCardValuePart,
|
||||
language: string,
|
||||
timeZone?: string
|
||||
): Intl.DateTimeFormat => {
|
||||
const cacheKey = `${language}|${timeZone || ""}|${part}`;
|
||||
const cached = DATE_PART_FORMATTERS.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(language, {
|
||||
...DATE_PART_OPTIONS[part],
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
});
|
||||
|
||||
DATE_PART_FORMATTERS.set(cacheKey, formatter);
|
||||
|
||||
return formatter;
|
||||
};
|
||||
|
||||
const formatDatePart = (
|
||||
part: ClockCardValuePart,
|
||||
date: Date,
|
||||
language: string,
|
||||
timeZone?: string
|
||||
) => getDatePartFormatter(part, language, timeZone).format(date);
|
||||
|
||||
/**
|
||||
* Applies a single date token to Intl.DateTimeFormat options.
|
||||
*/
|
||||
const applyDatePartOption = (
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
part: ClockCardDatePart
|
||||
) => {
|
||||
if (isDateSeparatorPart(part)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const partOptions = DATE_PART_OPTIONS[part];
|
||||
|
||||
if (partOptions.weekday) {
|
||||
options.weekday = partOptions.weekday;
|
||||
}
|
||||
|
||||
if (partOptions.day) {
|
||||
options.day = partOptions.day;
|
||||
}
|
||||
|
||||
if (partOptions.month) {
|
||||
options.month = partOptions.month;
|
||||
}
|
||||
|
||||
if (partOptions.year) {
|
||||
options.year = partOptions.year;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes configured date tokens while preserving their literal order.
|
||||
*/
|
||||
const normalizeDateParts = (
|
||||
parts: ClockCardConfig["date_format"]
|
||||
): ClockCardDatePart[] =>
|
||||
parts?.filter((part): part is ClockCardDatePart =>
|
||||
isClockCardDatePart(part)
|
||||
) || [];
|
||||
|
||||
/**
|
||||
* Returns a normalized date config from a card configuration object.
|
||||
*/
|
||||
export const getClockCardDateConfig = (
|
||||
config?: Pick<ClockCardConfig, "date_format">
|
||||
): ClockCardDateConfig => ({
|
||||
parts: normalizeDateParts(config?.date_format),
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks whether the clock configuration resolves to any visible date output.
|
||||
*/
|
||||
export const hasClockCardDate = (
|
||||
config?: Pick<ClockCardConfig, "date_format">
|
||||
): boolean => getClockCardDateConfig(config).parts.length > 0;
|
||||
|
||||
/**
|
||||
* Converts normalized date tokens into Intl.DateTimeFormat options.
|
||||
*
|
||||
* Separator tokens are ignored. If multiple tokens target the same Intl field,
|
||||
* the last one wins.
|
||||
*/
|
||||
export const getClockCardDateTimeFormatOptions = (
|
||||
dateConfig: ClockCardDateConfig
|
||||
): Intl.DateTimeFormatOptions => {
|
||||
const options: Intl.DateTimeFormatOptions = {};
|
||||
|
||||
dateConfig.parts.forEach((part) => {
|
||||
applyDatePartOption(options, part);
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the final date string from literal date tokens.
|
||||
*
|
||||
* Value tokens are localized through Intl.DateTimeFormat. Separator tokens are
|
||||
* always rendered literally. A default space is only inserted between adjacent
|
||||
* value tokens.
|
||||
*/
|
||||
export const formatClockCardDate = (
|
||||
date: Date,
|
||||
dateConfig: ClockCardDateConfig,
|
||||
language: string,
|
||||
timeZone?: string
|
||||
): string => {
|
||||
let result = "";
|
||||
let previousRenderedPartWasValue = false;
|
||||
|
||||
dateConfig.parts.forEach((part) => {
|
||||
if (isDateSeparatorPart(part)) {
|
||||
result += DATE_SEPARATORS[part];
|
||||
previousRenderedPartWasValue = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const value = formatDatePart(part, date, language, timeZone);
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousRenderedPartWasValue) {
|
||||
result += " ";
|
||||
}
|
||||
|
||||
result += value;
|
||||
previousRenderedPartWasValue = true;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -2,9 +2,16 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { ClockCardConfig } from "../types";
|
||||
import {
|
||||
formatClockCardDate,
|
||||
getClockCardDateConfig,
|
||||
hasClockCardDate,
|
||||
resolveClockCardLocale,
|
||||
} from "./clock-date-format";
|
||||
|
||||
function romanize12HourClock(num: number) {
|
||||
const numerals = [
|
||||
@@ -26,6 +33,11 @@ function romanize12HourClock(num: number) {
|
||||
return numerals[num];
|
||||
}
|
||||
|
||||
const DATE_UPDATE_INTERVAL = 60_000;
|
||||
const QUARTER_TICKS = Array.from({ length: 4 }, (_, i) => i);
|
||||
const HOUR_TICKS = Array.from({ length: 12 }, (_, i) => i);
|
||||
const MINUTE_TICKS = Array.from({ length: 60 }, (_, i) => i);
|
||||
|
||||
@customElement("hui-clock-card-analog")
|
||||
export class HuiClockCardAnalog extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@@ -40,42 +52,18 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
|
||||
@state() private _secondOffsetSec?: number;
|
||||
|
||||
private _initDate() {
|
||||
if (!this.config || !this.hass) {
|
||||
return;
|
||||
}
|
||||
@state() private _date?: string;
|
||||
|
||||
let locale = this.hass.locale;
|
||||
if (this.config.time_format) {
|
||||
locale = { ...locale, time_format: this.config.time_format };
|
||||
}
|
||||
private _dateInterval?: number;
|
||||
|
||||
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h12",
|
||||
timeZone:
|
||||
this.config.time_zone ||
|
||||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
|
||||
});
|
||||
private _timeZone?: string;
|
||||
|
||||
this._computeOffsets();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (!oldHass || oldHass.locale !== this.hass?.locale) {
|
||||
this._initDate();
|
||||
}
|
||||
}
|
||||
}
|
||||
private _language?: string;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("visibilitychange", this._handleVisibilityChange);
|
||||
this._computeOffsets();
|
||||
this._initDate();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
@@ -84,18 +72,80 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
"visibilitychange",
|
||||
this._handleVisibilityChange
|
||||
);
|
||||
this._stopDateTick();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("config") || changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (
|
||||
changedProps.has("config") ||
|
||||
!oldHass ||
|
||||
oldHass.locale !== this.hass?.locale
|
||||
) {
|
||||
this._initDate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
this._computeOffsets();
|
||||
this._updateDate();
|
||||
}
|
||||
};
|
||||
|
||||
private _initDate() {
|
||||
if (!this.config || !this.hass) {
|
||||
this._stopDateTick();
|
||||
this._date = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const { timeZone } = resolveClockCardLocale(this.hass, this.config);
|
||||
|
||||
this._language = this.hass.locale.language;
|
||||
this._timeZone = timeZone;
|
||||
|
||||
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h12",
|
||||
timeZone,
|
||||
});
|
||||
|
||||
this._computeOffsets();
|
||||
this._updateDate();
|
||||
|
||||
if (this.isConnected && hasClockCardDate(this.config)) {
|
||||
this._startDateTick();
|
||||
} else {
|
||||
this._stopDateTick();
|
||||
}
|
||||
}
|
||||
|
||||
private _startDateTick() {
|
||||
this._stopDateTick();
|
||||
this._dateInterval = window.setInterval(
|
||||
() => this._updateDate(),
|
||||
DATE_UPDATE_INTERVAL
|
||||
);
|
||||
}
|
||||
|
||||
private _stopDateTick() {
|
||||
if (this._dateInterval) {
|
||||
clearInterval(this._dateInterval);
|
||||
this._dateInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _computeOffsets() {
|
||||
if (!this._dateTimeFormat) return;
|
||||
|
||||
const parts = this._dateTimeFormat.formatToParts();
|
||||
const date = new Date();
|
||||
|
||||
const parts = this._dateTimeFormat.formatToParts(date);
|
||||
const hourStr = parts.find((p) => p.type === "hour")?.value;
|
||||
const minuteStr = parts.find((p) => p.type === "minute")?.value;
|
||||
const secondStr = parts.find((p) => p.type === "second")?.value;
|
||||
@@ -103,7 +153,7 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
const hour = hourStr ? parseInt(hourStr, 10) : 0;
|
||||
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
|
||||
const second = secondStr ? parseInt(secondStr, 10) : 0;
|
||||
const ms = new Date().getMilliseconds();
|
||||
const ms = date.getMilliseconds();
|
||||
const secondsWithMs = second + ms / 1000;
|
||||
|
||||
const hour12 = hour % 12;
|
||||
@@ -113,16 +163,44 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
|
||||
}
|
||||
|
||||
private _updateDate() {
|
||||
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
|
||||
this._date = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const dateConfig = getClockCardDateConfig(this.config);
|
||||
this._date = formatClockCardDate(
|
||||
new Date(),
|
||||
dateConfig,
|
||||
this._language,
|
||||
this._timeZone
|
||||
);
|
||||
}
|
||||
|
||||
private _computeClock = memoizeOne((config: ClockCardConfig) => {
|
||||
const faceParts = config.face_style?.split("_");
|
||||
const dateConfig = getClockCardDateConfig(config);
|
||||
const showDate = hasClockCardDate(config);
|
||||
const isLongDate =
|
||||
dateConfig.parts.includes("month-long") ||
|
||||
dateConfig.parts.includes("weekday-long");
|
||||
|
||||
return {
|
||||
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
|
||||
isNumbers: faceParts?.includes("numbers") ?? false,
|
||||
isRoman: faceParts?.includes("roman") ?? false,
|
||||
isUpright: faceParts?.includes("upright") ?? false,
|
||||
showDate,
|
||||
isLongDate,
|
||||
};
|
||||
});
|
||||
|
||||
render() {
|
||||
if (!this.config) return nothing;
|
||||
|
||||
const sizeClass = this.config.clock_size
|
||||
? `size-${this.config.clock_size}`
|
||||
: "";
|
||||
|
||||
const isNumbers = this.config?.face_style?.startsWith("numbers");
|
||||
const isRoman = this.config?.face_style?.startsWith("roman");
|
||||
const isUpright = this.config?.face_style?.endsWith("upright");
|
||||
const { sizeClass, isNumbers, isRoman, isUpright, isLongDate, showDate } =
|
||||
this._computeClock(this.config);
|
||||
|
||||
const indicator = (number?: number) => html`
|
||||
<div
|
||||
@@ -163,14 +241,14 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
})}
|
||||
>
|
||||
${this.config.ticks === "quarter"
|
||||
? Array.from({ length: 4 }, (_, i) => i).map(
|
||||
? QUARTER_TICKS.map(
|
||||
(i) =>
|
||||
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
|
||||
html`
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="tick hour"
|
||||
style=${`--tick-rotation: ${i * 90}deg;`}
|
||||
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
|
||||
>
|
||||
${indicator([12, 3, 6, 9][i])}
|
||||
</div>
|
||||
@@ -178,28 +256,30 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
)
|
||||
: !this.config.ticks || // Default to hour ticks
|
||||
this.config.ticks === "hour"
|
||||
? Array.from({ length: 12 }, (_, i) => i).map(
|
||||
? HOUR_TICKS.map(
|
||||
(i) =>
|
||||
// 12 ticks (1-12)
|
||||
html`
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="tick hour"
|
||||
style=${`--tick-rotation: ${i * 30}deg;`}
|
||||
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
|
||||
>
|
||||
${indicator(((i + 11) % 12) + 1)}
|
||||
</div>
|
||||
`
|
||||
)
|
||||
: this.config.ticks === "minute"
|
||||
? Array.from({ length: 60 }, (_, i) => i).map(
|
||||
? MINUTE_TICKS.map(
|
||||
(i) =>
|
||||
// 60 ticks (1-60)
|
||||
html`
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
|
||||
style=${`--tick-rotation: ${i * 6}deg;`}
|
||||
style=${styleMap({
|
||||
"--tick-rotation": `${i * 6}deg`,
|
||||
})}
|
||||
>
|
||||
${i % 5 === 0
|
||||
? indicator(((i / 5 + 11) % 12) + 1)
|
||||
@@ -208,14 +288,34 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
`
|
||||
)
|
||||
: nothing}
|
||||
${showDate
|
||||
? html`<div
|
||||
class=${classMap({
|
||||
date: true,
|
||||
[sizeClass]: true,
|
||||
"long-date": isLongDate,
|
||||
})}
|
||||
>
|
||||
${this._date
|
||||
?.split("\n")
|
||||
.map((line, index) =>
|
||||
index > 0 ? html`<br />${line}` : line
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="center-dot"></div>
|
||||
<div
|
||||
class="hand hour"
|
||||
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
|
||||
style=${styleMap({
|
||||
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
|
||||
})}
|
||||
></div>
|
||||
<div
|
||||
class="hand minute"
|
||||
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
|
||||
style=${styleMap({
|
||||
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
|
||||
})}
|
||||
></div>
|
||||
${this.config.show_seconds
|
||||
? html`<div
|
||||
@@ -224,11 +324,13 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
second: true,
|
||||
step: this.config.seconds_motion === "tick",
|
||||
})}
|
||||
style=${`animation-delay: -${
|
||||
(this.config.seconds_motion === "tick"
|
||||
? Math.floor(this._secondOffsetSec ?? 0)
|
||||
: (this._secondOffsetSec ?? 0)) as number
|
||||
}s;`}
|
||||
style=${styleMap({
|
||||
"animation-delay": `-${
|
||||
this.config.seconds_motion === "tick"
|
||||
? Math.floor(this._secondOffsetSec ?? 0)
|
||||
: (this._secondOffsetSec ?? 0)
|
||||
}s`,
|
||||
})}
|
||||
></div>`
|
||||
: nothing}
|
||||
</div>
|
||||
@@ -407,6 +509,36 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
transform: translate(-50%, 0) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
position: absolute;
|
||||
top: 68%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: block;
|
||||
color: var(--primary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
max-width: 87%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.date.long-date:not(.size-medium):not(.size-large) {
|
||||
font-size: var(--ha-font-size-xs);
|
||||
}
|
||||
|
||||
.date.size-medium {
|
||||
font-size: var(--ha-font-size-l);
|
||||
}
|
||||
|
||||
.date.size-large {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import type { ClockCardConfig } from "../types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { useAmPm } from "../../../../common/datetime/use_am_pm";
|
||||
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
|
||||
import {
|
||||
formatClockCardDate,
|
||||
getClockCardDateConfig,
|
||||
hasClockCardDate,
|
||||
resolveClockCardLocale,
|
||||
} from "./clock-date-format";
|
||||
|
||||
const INTERVAL = 1000;
|
||||
|
||||
@@ -24,37 +29,50 @@ export class HuiClockCardDigital extends LitElement {
|
||||
|
||||
@state() private _timeAmPm?: string;
|
||||
|
||||
@state() private _date?: string;
|
||||
|
||||
private _tickInterval?: undefined | number;
|
||||
|
||||
private _lastDateMinute?: string;
|
||||
|
||||
private _timeZone?: string;
|
||||
|
||||
private _language?: string;
|
||||
|
||||
private _initDate() {
|
||||
if (!this.config || !this.hass) {
|
||||
this._date = undefined;
|
||||
this._lastDateMinute = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
let locale = this.hass?.locale;
|
||||
|
||||
if (this.config?.time_format) {
|
||||
locale = { ...locale, time_format: this.config.time_format };
|
||||
}
|
||||
const { locale, timeZone } = resolveClockCardLocale(this.hass, this.config);
|
||||
|
||||
const h12 = useAmPm(locale);
|
||||
this._language = this.hass.locale.language;
|
||||
this._timeZone = timeZone;
|
||||
|
||||
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
|
||||
hour: h12 ? "numeric" : "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: h12 ? "h12" : "h23",
|
||||
timeZone:
|
||||
this.config?.time_zone ||
|
||||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
|
||||
timeZone,
|
||||
});
|
||||
|
||||
this._lastDateMinute = undefined;
|
||||
|
||||
this._tick();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("hass")) {
|
||||
if (changedProps.has("config") || changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (!oldHass || oldHass.locale !== this.hass?.locale) {
|
||||
if (
|
||||
changedProps.has("config") ||
|
||||
!oldHass ||
|
||||
oldHass.locale !== this.hass?.locale
|
||||
) {
|
||||
this._initDate();
|
||||
}
|
||||
}
|
||||
@@ -71,6 +89,7 @@ export class HuiClockCardDigital extends LitElement {
|
||||
}
|
||||
|
||||
private _startTick() {
|
||||
this._stopTick();
|
||||
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
|
||||
this._tick();
|
||||
}
|
||||
@@ -85,7 +104,8 @@ export class HuiClockCardDigital extends LitElement {
|
||||
private _tick() {
|
||||
if (!this._dateTimeFormat) return;
|
||||
|
||||
const parts = this._dateTimeFormat.formatToParts();
|
||||
const date = new Date();
|
||||
const parts = this._dateTimeFormat.formatToParts(date);
|
||||
|
||||
this._timeHour = parts.find((part) => part.type === "hour")?.value;
|
||||
this._timeMinute = parts.find((part) => part.type === "minute")?.value;
|
||||
@@ -93,6 +113,33 @@ export class HuiClockCardDigital extends LitElement {
|
||||
? parts.find((part) => part.type === "second")?.value
|
||||
: undefined;
|
||||
this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value;
|
||||
|
||||
this._updateDate(date);
|
||||
}
|
||||
|
||||
private _updateDate(date: Date) {
|
||||
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
|
||||
this._date = undefined;
|
||||
this._lastDateMinute = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this._timeMinute !== undefined &&
|
||||
this._timeMinute === this._lastDateMinute &&
|
||||
this._date !== undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dateConfig = getClockCardDateConfig(this.config);
|
||||
this._date = formatClockCardDate(
|
||||
date,
|
||||
dateConfig,
|
||||
this._language,
|
||||
this._timeZone
|
||||
);
|
||||
this._lastDateMinute = this._timeMinute;
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -101,18 +148,30 @@ export class HuiClockCardDigital extends LitElement {
|
||||
const sizeClass = this.config.clock_size
|
||||
? `size-${this.config.clock_size}`
|
||||
: "";
|
||||
const showDate = hasClockCardDate(this.config);
|
||||
|
||||
return html`
|
||||
<div class="time-parts ${sizeClass}">
|
||||
<div class="time-part hour">${this._timeHour}</div>
|
||||
<div class="time-part minute">${this._timeMinute}</div>
|
||||
${this._timeSecond !== undefined
|
||||
? html`<div class="time-part second">${this._timeSecond}</div>`
|
||||
: nothing}
|
||||
${this._timeAmPm !== undefined
|
||||
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
|
||||
: nothing}
|
||||
<div class="clock-container">
|
||||
<div class="time-parts ${sizeClass}">
|
||||
<div class="time-part hour">${this._timeHour}</div>
|
||||
<div class="time-part minute">${this._timeMinute}</div>
|
||||
${this._timeSecond !== undefined
|
||||
? html`<div class="time-part second">${this._timeSecond}</div>`
|
||||
: nothing}
|
||||
${this._timeAmPm !== undefined
|
||||
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
${showDate
|
||||
? html`<div class="date-container">
|
||||
<div class="date ${sizeClass}">
|
||||
${this._date
|
||||
?.split("\n")
|
||||
.map((line, index) => (index > 0 ? html`<br />${line}` : line))}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -121,6 +180,17 @@ export class HuiClockCardDigital extends LitElement {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.date-container {
|
||||
width: 100%;
|
||||
margin-top: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.time-parts {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
@@ -188,6 +258,21 @@ export class HuiClockCardDigital extends LitElement {
|
||||
content: ":";
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.date {
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
font-size: var(--ha-font-size-s);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.date.size-medium {
|
||||
font-size: var(--ha-font-size-l);
|
||||
}
|
||||
|
||||
.date.size-large {
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { mdiTransmissionTower } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/tile/ha-tile-icon";
|
||||
import "../../../../components/tile/ha-tile-info";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
getEnergyDataCollection,
|
||||
getSummedData,
|
||||
validateEnergyCollectionKey,
|
||||
} from "../../../../data/energy";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { LovelaceCard } from "../../types";
|
||||
import type { EnergyGridBalanceCardConfig } from "../types";
|
||||
import { hasConfigChanged } from "../../common/has-changed";
|
||||
|
||||
@customElement("hui-energy-grid-balance-card")
|
||||
class HuiEnergyGridBalanceCard
|
||||
extends SubscribeMixin(LitElement)
|
||||
implements LovelaceCard
|
||||
{
|
||||
public static async getConfigElement() {
|
||||
await import("../../editor/config-elements/hui-energy-graph-card-editor");
|
||||
return document.createElement("hui-energy-graph-card-editor");
|
||||
}
|
||||
|
||||
public static getStubConfig(
|
||||
_hass: HomeAssistant,
|
||||
_entities: string[],
|
||||
_entitiesFill: string[]
|
||||
): EnergyGridBalanceCardConfig {
|
||||
return {
|
||||
type: "energy-grid-balance",
|
||||
};
|
||||
}
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _config?: EnergyGridBalanceCardConfig;
|
||||
|
||||
@state() private _data?: EnergyData;
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
return [
|
||||
getEnergyDataCollection(this.hass!, {
|
||||
key: this._config?.collection_key,
|
||||
}).subscribe((data) => {
|
||||
this._data = data;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
public setConfig(config: EnergyGridBalanceCardConfig): void {
|
||||
if (config.collection_key) {
|
||||
validateEnergyCollectionKey(config.collection_key);
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
return (
|
||||
hasConfigChanged(this, changedProps) ||
|
||||
changedProps.size > 1 ||
|
||||
!changedProps.has("hass")
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (!this._data) {
|
||||
return html`${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.loading"
|
||||
)}`;
|
||||
}
|
||||
|
||||
const { summedData } = getSummedData(this._data);
|
||||
|
||||
const imported = summedData.total.from_grid ?? 0;
|
||||
const exported = summedData.total.to_grid ?? 0;
|
||||
const net = imported - exported;
|
||||
|
||||
const fmt = (value: number) =>
|
||||
formatNumber(
|
||||
value,
|
||||
this.hass.locale,
|
||||
Math.abs(value) < 0.01
|
||||
? { maximumSignificantDigits: 2 }
|
||||
: { maximumFractionDigits: 2 }
|
||||
);
|
||||
|
||||
const isConsumption = net >= 0;
|
||||
const max = Math.max(imported, exported);
|
||||
const leftPercent = max > 0 ? (exported / max) * 100 : 0;
|
||||
const rightPercent = max > 0 ? (imported / max) * 100 : 0;
|
||||
const netBarWidth = max > 0 ? (Math.abs(net) / max) * 100 : 0;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="content">
|
||||
<ha-tile-icon>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiTransmissionTower}
|
||||
></ha-svg-icon>
|
||||
</ha-tile-icon>
|
||||
<ha-tile-info>
|
||||
<span slot="primary">
|
||||
${this._config.title ||
|
||||
this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.grid_balance.title"
|
||||
)}
|
||||
</span>
|
||||
<span slot="secondary" class="equation">
|
||||
<span class="imported" id="eq-imported">
|
||||
${fmt(imported)} kWh
|
||||
</span>
|
||||
<ha-tooltip for="eq-imported" placement="top">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.grid_balance.imported",
|
||||
{ value: fmt(imported) }
|
||||
)}
|
||||
</ha-tooltip>
|
||||
<span class="operator"> - </span>
|
||||
<span class="exported" id="eq-exported">
|
||||
${fmt(exported)} kWh
|
||||
</span>
|
||||
<ha-tooltip for="eq-exported" placement="top">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.grid_balance.exported",
|
||||
{ value: fmt(exported) }
|
||||
)}
|
||||
</ha-tooltip>
|
||||
<span class="operator"> = </span>
|
||||
<span
|
||||
class="net ${isConsumption ? "consumption" : "return"}"
|
||||
id="eq-net"
|
||||
>
|
||||
${fmt(net)} kWh
|
||||
</span>
|
||||
<ha-tooltip for="eq-net" placement="top">
|
||||
${this.hass.localize(
|
||||
`ui.panel.lovelace.cards.energy.grid_balance.net_${isConsumption ? "import" : "export"}`,
|
||||
{ value: fmt(Math.abs(net)) }
|
||||
)}
|
||||
</ha-tooltip>
|
||||
</span>
|
||||
</ha-tile-info>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<div class="bar-half bar-left">
|
||||
<div
|
||||
id="bar-exported"
|
||||
class="bar-fill return"
|
||||
style="width: ${leftPercent}%"
|
||||
></div>
|
||||
<ha-tooltip for="bar-exported" placement="top">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.grid_balance.exported",
|
||||
{ value: fmt(exported) }
|
||||
)}
|
||||
</ha-tooltip>
|
||||
${!isConsumption
|
||||
? html`<div
|
||||
id="bar-net-left"
|
||||
class="bar-net return"
|
||||
style="width: ${netBarWidth}%"
|
||||
></div>
|
||||
<ha-tooltip for="bar-net-left" placement="top">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.grid_balance.net_export",
|
||||
{
|
||||
value: fmt(Math.abs(net)),
|
||||
}
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="bar-center"></div>
|
||||
<div class="bar-half bar-right">
|
||||
<div
|
||||
id="bar-imported"
|
||||
class="bar-fill consumption"
|
||||
style="width: ${rightPercent}%"
|
||||
></div>
|
||||
<ha-tooltip for="bar-imported" placement="top">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.grid_balance.imported",
|
||||
{ value: fmt(imported) }
|
||||
)}
|
||||
</ha-tooltip>
|
||||
${isConsumption
|
||||
? html`<div
|
||||
id="bar-net-right"
|
||||
class="bar-net consumption"
|
||||
style="width: ${netBarWidth}%"
|
||||
></div>
|
||||
<ha-tooltip for="bar-net-right" placement="top">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.grid_balance.net_import",
|
||||
{
|
||||
value: fmt(Math.abs(net)),
|
||||
}
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--ha-space-3);
|
||||
gap: var(--ha-space-3);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
ha-tile-icon {
|
||||
--tile-icon-color: var(--state-inactive-color);
|
||||
}
|
||||
|
||||
.equation {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.operator {
|
||||
color: var(--secondary-text-color);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.imported {
|
||||
color: var(--energy-grid-consumption-color);
|
||||
}
|
||||
|
||||
.exported {
|
||||
color: var(--energy-grid-return-color);
|
||||
}
|
||||
|
||||
.net.consumption {
|
||||
color: var(--energy-grid-consumption-color);
|
||||
}
|
||||
|
||||
.net.return {
|
||||
color: var(--energy-grid-return-color);
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 42px;
|
||||
margin: 0 var(--ha-space-3) var(--ha-space-4);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.bar-half {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: var(--ha-border-width-sm) solid;
|
||||
}
|
||||
|
||||
.bar-left {
|
||||
flex-direction: row-reverse;
|
||||
border-radius: var(--ha-border-radius-lg) 0 0 var(--ha-border-radius-lg);
|
||||
border-color: var(--ha-color-border-neutral-quiet);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--energy-grid-return-color) 30%,
|
||||
transparent
|
||||
);
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.bar-right {
|
||||
border-radius: 0 var(--ha-border-radius-lg) var(--ha-border-radius-lg) 0;
|
||||
border-color: var(--ha-color-border-neutral-quiet);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--energy-grid-consumption-color) 30%,
|
||||
transparent
|
||||
);
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
opacity: 0.3;
|
||||
transition: width var(--ha-animation-duration-fast) ease-in-out;
|
||||
}
|
||||
|
||||
.bar-left .bar-fill {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.bar-right .bar-fill {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.bar-fill.consumption {
|
||||
background-color: var(--energy-grid-consumption-color);
|
||||
}
|
||||
|
||||
.bar-fill.return {
|
||||
background-color: var(--energy-grid-return-color);
|
||||
}
|
||||
|
||||
.bar-net {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
transition: width var(--ha-animation-duration-fast) ease-in-out;
|
||||
}
|
||||
|
||||
.bar-left .bar-net {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.bar-right .bar-net {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.bar-net.consumption {
|
||||
background-color: var(--energy-grid-consumption-color);
|
||||
}
|
||||
|
||||
.bar-net.return {
|
||||
background-color: var(--energy-grid-return-color);
|
||||
}
|
||||
|
||||
.bar-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -6px;
|
||||
transform: translateX(-50%);
|
||||
width: 2px;
|
||||
height: calc(100% + 12px);
|
||||
background: var(--primary-text-color);
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-energy-grid-balance-card": HuiEnergyGridBalanceCard;
|
||||
}
|
||||
}
|
||||
@@ -663,12 +663,6 @@ class HuiPowerSankeyCard
|
||||
let used_total_remaining = Math.max(used_total, 0);
|
||||
|
||||
let grid_to_battery = 0;
|
||||
let battery_to_grid = 0;
|
||||
let solar_to_battery = 0;
|
||||
let solar_to_grid = 0;
|
||||
let used_solar = 0;
|
||||
let used_battery = 0;
|
||||
let used_grid = 0;
|
||||
|
||||
// Handle excess grid input to battery first
|
||||
const excess_grid_in_after_consumption = Math.max(
|
||||
@@ -680,40 +674,34 @@ class HuiPowerSankeyCard
|
||||
grid_remaining -= excess_grid_in_after_consumption;
|
||||
|
||||
// Solar -> Battery_In
|
||||
solar_to_battery = Math.min(solar_remaining, to_battery_remaining);
|
||||
const solar_to_battery = Math.min(solar_remaining, to_battery_remaining);
|
||||
to_battery_remaining -= solar_to_battery;
|
||||
solar_remaining -= solar_to_battery;
|
||||
|
||||
// Solar -> Grid_Out
|
||||
solar_to_grid = Math.min(solar_remaining, to_grid_remaining);
|
||||
const solar_to_grid = Math.min(solar_remaining, to_grid_remaining);
|
||||
to_grid_remaining -= solar_to_grid;
|
||||
solar_remaining -= solar_to_grid;
|
||||
|
||||
// Battery_Out -> Grid_Out
|
||||
battery_to_grid = Math.min(battery_remaining, to_grid_remaining);
|
||||
const battery_to_grid = Math.min(battery_remaining, to_grid_remaining);
|
||||
battery_remaining -= battery_to_grid;
|
||||
to_grid_remaining -= battery_to_grid;
|
||||
|
||||
// Grid_In -> Battery_In (second pass)
|
||||
const grid_to_battery_2 = Math.min(grid_remaining, to_battery_remaining);
|
||||
grid_to_battery += grid_to_battery_2;
|
||||
grid_remaining -= grid_to_battery_2;
|
||||
to_battery_remaining -= grid_to_battery_2;
|
||||
|
||||
// Solar -> Consumption
|
||||
used_solar = Math.min(used_total_remaining, solar_remaining);
|
||||
const used_solar = Math.min(used_total_remaining, solar_remaining);
|
||||
used_total_remaining -= used_solar;
|
||||
solar_remaining -= used_solar;
|
||||
|
||||
// Battery_Out -> Consumption
|
||||
used_battery = Math.min(battery_remaining, used_total_remaining);
|
||||
battery_remaining -= used_battery;
|
||||
const used_battery = Math.min(battery_remaining, used_total_remaining);
|
||||
used_total_remaining -= used_battery;
|
||||
|
||||
// Grid_In -> Consumption
|
||||
used_grid = Math.min(used_total_remaining, grid_remaining);
|
||||
grid_remaining -= used_grid;
|
||||
used_total_remaining -= used_grid;
|
||||
const used_grid = Math.min(used_total_remaining, grid_remaining);
|
||||
|
||||
return {
|
||||
solar,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
|
||||
import { computeCardSize } from "../common/compute-card-size";
|
||||
import { getConfigEntityId } from "../common/get-config-entity-id";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { tryCreateCardElement } from "../create-element/create-card-element";
|
||||
import { createErrorCardElement } from "../create-element/create-element-base";
|
||||
@@ -169,6 +170,13 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (changedProps.has("config")) {
|
||||
this._conditionContext = {
|
||||
...this._conditionContext,
|
||||
entity_id: this.config ? getConfigEntityId(this.config) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (!this._element) {
|
||||
this.load();
|
||||
}
|
||||
|
||||
@@ -240,6 +240,10 @@ export interface EnergyGridNeutralityGaugeCardConfig extends EnergyCardConfig {
|
||||
type: "energy-grid-neutrality-gauge";
|
||||
}
|
||||
|
||||
export interface EnergyGridBalanceCardConfig extends EnergyCardConfig {
|
||||
type: "energy-grid-balance";
|
||||
}
|
||||
|
||||
export interface EnergyCarbonGaugeCardConfig extends EnergyCardConfig {
|
||||
type: "energy-carbon-consumed-gauge";
|
||||
}
|
||||
@@ -442,12 +446,29 @@ export interface ClockCardConfig extends LovelaceCardConfig {
|
||||
time_format?: TimeFormat;
|
||||
time_zone?: string;
|
||||
no_background?: boolean;
|
||||
date_format?: ClockCardDatePart[];
|
||||
// Analog clock options
|
||||
border?: boolean;
|
||||
ticks?: "none" | "quarter" | "hour" | "minute";
|
||||
face_style?: "markers" | "numbers_upright" | "roman";
|
||||
}
|
||||
|
||||
export type ClockCardDatePart =
|
||||
| "weekday-short"
|
||||
| "weekday-long"
|
||||
| "day-numeric"
|
||||
| "day-2-digit"
|
||||
| "month-short"
|
||||
| "month-long"
|
||||
| "month-numeric"
|
||||
| "month-2-digit"
|
||||
| "year-2-digit"
|
||||
| "year-numeric"
|
||||
| "separator-dash"
|
||||
| "separator-slash"
|
||||
| "separator-dot"
|
||||
| "separator-new-line";
|
||||
|
||||
export interface MediaControlCardConfig extends LovelaceCardConfig {
|
||||
entity: string;
|
||||
name?: string | EntityNameItem | EntityNameItem[];
|
||||
|
||||
@@ -70,7 +70,6 @@ export const computeTooltip = (hass: HomeAssistant, config: Config): string => {
|
||||
}
|
||||
|
||||
let stateName = "";
|
||||
let tooltip = "";
|
||||
|
||||
if (config.entity) {
|
||||
stateName =
|
||||
@@ -92,7 +91,5 @@ export const computeTooltip = (hass: HomeAssistant, config: Config): string => {
|
||||
|
||||
const newline = tapTooltip && holdTooltip ? "\n" : "";
|
||||
|
||||
tooltip = tapTooltip + newline + holdTooltip;
|
||||
|
||||
return tooltip;
|
||||
return tapTooltip + newline + holdTooltip;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
import type { AttributePart } from "lit";
|
||||
import { noChange } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@@ -220,7 +220,6 @@ export const computeCards = (
|
||||
if (
|
||||
titlePrefix &&
|
||||
stateObj &&
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
(name = stripPrefixFromEntityName(
|
||||
computeStateName(stateObj),
|
||||
titlePrefix
|
||||
@@ -234,7 +233,6 @@ export const computeCards = (
|
||||
const entityConf =
|
||||
titlePrefix &&
|
||||
stateObj &&
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
(name = stripPrefixFromEntityName(
|
||||
computeStateName(stateObj),
|
||||
titlePrefix
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user