mirror of
https://github.com/home-assistant/frontend.git
synced 2025-09-18 09:29:42 +00:00
Compare commits
275 Commits
fix_new_sc
...
20250102.0
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6c9df587e7 | ||
![]() |
8f58681d83 | ||
![]() |
4a16d9bd44 | ||
![]() |
fcc9da6d85 | ||
![]() |
e03dc2c382 | ||
![]() |
be967940a2 | ||
![]() |
64ad37ed6a | ||
![]() |
01bc45c78b | ||
![]() |
2206644c47 | ||
![]() |
486038c426 | ||
![]() |
711f721007 | ||
![]() |
3b8bc242fe | ||
![]() |
7e80eed003 | ||
![]() |
a7ef498d75 | ||
![]() |
a5de6ff3af | ||
![]() |
806cc2c608 | ||
![]() |
48a160f057 | ||
![]() |
c697843c34 | ||
![]() |
317a2f5b21 | ||
![]() |
220011f15f | ||
![]() |
e8af454705 | ||
![]() |
713b5c7cf7 | ||
![]() |
8b17286fb6 | ||
![]() |
f3705a7e1d | ||
![]() |
d0123b2cce | ||
![]() |
884c22f92b | ||
![]() |
700690474c | ||
![]() |
4686808e53 | ||
![]() |
cf1df712e4 | ||
![]() |
c338e9cb30 | ||
![]() |
8e8fd89d56 | ||
![]() |
f1c360c550 | ||
![]() |
b429ecc376 | ||
![]() |
6d8422513a | ||
![]() |
cb0a48265a | ||
![]() |
c9082724a8 | ||
![]() |
fea83c0873 | ||
![]() |
d3b4014182 | ||
![]() |
5c7fe04562 | ||
![]() |
44e26c925b | ||
![]() |
205dd3f968 | ||
![]() |
86133a0696 | ||
![]() |
2105db9104 | ||
![]() |
f416b1b5da | ||
![]() |
6a345d86a6 | ||
![]() |
a04e9b68bd | ||
![]() |
f7dbd38c2e | ||
![]() |
7150016375 | ||
![]() |
c3507abd9c | ||
![]() |
fc8a8b28c2 | ||
![]() |
637fe37ef4 | ||
![]() |
657bfc82ca | ||
![]() |
44423812f4 | ||
![]() |
a6a76155e5 | ||
![]() |
8c18d816b6 | ||
![]() |
361caafab9 | ||
![]() |
94f679e387 | ||
![]() |
85bd6432a9 | ||
![]() |
00c1dfa1d2 | ||
![]() |
fb72e1fb9c | ||
![]() |
152d2d0bdf | ||
![]() |
e06aa52a21 | ||
![]() |
49aa935490 | ||
![]() |
cead1e355d | ||
![]() |
edc99b5d6c | ||
![]() |
b38963b214 | ||
![]() |
0d99531855 | ||
![]() |
9cde7637ca | ||
![]() |
edc08994b3 | ||
![]() |
fc0907ef72 | ||
![]() |
bdb28246fc | ||
![]() |
0582798cde | ||
![]() |
331385794c | ||
![]() |
8e71bd7e82 | ||
![]() |
664cc9b33d | ||
![]() |
6757cda7f0 | ||
![]() |
97e6313890 | ||
![]() |
3cfba7b960 | ||
![]() |
5fb384ad31 | ||
![]() |
76cb9ce807 | ||
![]() |
0e61596f5c | ||
![]() |
061b6af812 | ||
![]() |
fd95ab5518 | ||
![]() |
5a0225b86a | ||
![]() |
8b79fc5848 | ||
![]() |
5513da51a8 | ||
![]() |
56f9165323 | ||
![]() |
6afcd4d770 | ||
![]() |
52e1f9315e | ||
![]() |
a00f645921 | ||
![]() |
faf3bb2644 | ||
![]() |
31d98ec935 | ||
![]() |
7d0a269f1b | ||
![]() |
147098f0fd | ||
![]() |
d4cbfd9583 | ||
![]() |
a35ba38a2d | ||
![]() |
8fa36c8226 | ||
![]() |
5ccc3365fe | ||
![]() |
dcf97d4667 | ||
![]() |
4b7acbb766 | ||
![]() |
7900eb4054 | ||
![]() |
53caef8f92 | ||
![]() |
14bebc76b0 | ||
![]() |
8b6382448f | ||
![]() |
5cd6f22e99 | ||
![]() |
523c38a83e | ||
![]() |
0a28bbdd72 | ||
![]() |
e58bef7795 | ||
![]() |
d403532fc1 | ||
![]() |
4de8b562bd | ||
![]() |
31e85836f0 | ||
![]() |
e1359781a5 | ||
![]() |
379bc3a4e3 | ||
![]() |
b3b0006ba3 | ||
![]() |
cd44b33201 | ||
![]() |
33df805168 | ||
![]() |
fad435ea10 | ||
![]() |
b35f9944ea | ||
![]() |
dc799bf691 | ||
![]() |
1f7929bb3d | ||
![]() |
fb228dc918 | ||
![]() |
c66f5e2d8a | ||
![]() |
d71b29d089 | ||
![]() |
6b230b6142 | ||
![]() |
c532a9023a | ||
![]() |
ecc704e6ac | ||
![]() |
5470c8f250 | ||
![]() |
d21f249aac | ||
![]() |
907299b139 | ||
![]() |
37aa2bd869 | ||
![]() |
f1f53b9f24 | ||
![]() |
49d9c7f392 | ||
![]() |
65860a3142 | ||
![]() |
3b52d3d302 | ||
![]() |
2fe6203eae | ||
![]() |
92b02e39c9 | ||
![]() |
b693fd1edc | ||
![]() |
b84e00b312 | ||
![]() |
973fd51639 | ||
![]() |
e0494ccb57 | ||
![]() |
95559cbc2a | ||
![]() |
3da13b823a | ||
![]() |
c022871ead | ||
![]() |
c4fcbf0613 | ||
![]() |
79921745a8 | ||
![]() |
40a4255045 | ||
![]() |
2d902a0688 | ||
![]() |
8a46ef6168 | ||
![]() |
ba3d37b550 | ||
![]() |
88d0247217 | ||
![]() |
1c076d22a6 | ||
![]() |
0ecdae2551 | ||
![]() |
6f8ba6afac | ||
![]() |
ad1c32a880 | ||
![]() |
48819a59e7 | ||
![]() |
2718801c69 | ||
![]() |
2b43f5f8c8 | ||
![]() |
0ef23cd712 | ||
![]() |
7370d1e0dd | ||
![]() |
da7d3e118c | ||
![]() |
d4188d9aee | ||
![]() |
8722157623 | ||
![]() |
6e2f0d8c9b | ||
![]() |
08459394a6 | ||
![]() |
ee292f900f | ||
![]() |
3aaf08ac03 | ||
![]() |
d31f4a5f1d | ||
![]() |
875ab0cb97 | ||
![]() |
678af025ac | ||
![]() |
1fd38d085f | ||
![]() |
ec324ab09f | ||
![]() |
efcd57934a | ||
![]() |
1efe61445f | ||
![]() |
6db9bf800a | ||
![]() |
65458538a7 | ||
![]() |
9ddeb3734f | ||
![]() |
c89fc188a5 | ||
![]() |
d366471058 | ||
![]() |
9951c162a1 | ||
![]() |
0c7d689b5a | ||
![]() |
655ce05efe | ||
![]() |
2a6e562d37 | ||
![]() |
98d1954812 | ||
![]() |
72a2f54598 | ||
![]() |
9364ea060a | ||
![]() |
f8dfdcb090 | ||
![]() |
3d78a7821a | ||
![]() |
e923d610ce | ||
![]() |
8b25fe88a4 | ||
![]() |
3768be55ff | ||
![]() |
47cf17ab50 | ||
![]() |
d2ec24f32a | ||
![]() |
69ed736080 | ||
![]() |
7d3c77008d | ||
![]() |
27dbabc6bf | ||
![]() |
27ce395d68 | ||
![]() |
145a536156 | ||
![]() |
e9b2a83411 | ||
![]() |
784b7e4d04 | ||
![]() |
f4bf999ae2 | ||
![]() |
9c04f57e35 | ||
![]() |
f0ddc408e8 | ||
![]() |
df4d5a4567 | ||
![]() |
0a413cba03 | ||
![]() |
7e2217b542 | ||
![]() |
93947d76a2 | ||
![]() |
18cce45b88 | ||
![]() |
86f1af6682 | ||
![]() |
c8f58c7bc9 | ||
![]() |
e5e168803a | ||
![]() |
3436a023f6 | ||
![]() |
84322a21fe | ||
![]() |
ec20f7e2c4 | ||
![]() |
3579d82e8e | ||
![]() |
70532ac3bf | ||
![]() |
96b9d25bc5 | ||
![]() |
91777d45b0 | ||
![]() |
0582b8430d | ||
![]() |
63d97398c1 | ||
![]() |
3552417b39 | ||
![]() |
a6cbbfe1a4 | ||
![]() |
48f5d17060 | ||
![]() |
c713106948 | ||
![]() |
142e674020 | ||
![]() |
e4fc21c991 | ||
![]() |
f9844e8e58 | ||
![]() |
86f9909ac9 | ||
![]() |
629ae3fbf3 | ||
![]() |
ddd2c177b5 | ||
![]() |
829de4a073 | ||
![]() |
0df8b96133 | ||
![]() |
443921a97c | ||
![]() |
54bc0525f7 | ||
![]() |
72f8f020fc | ||
![]() |
7f31acf764 | ||
![]() |
8c5862e4ce | ||
![]() |
f3e0df93b5 | ||
![]() |
c6c5ea34d3 | ||
![]() |
287a068ada | ||
![]() |
8ca52820b1 | ||
![]() |
cd8900dd26 | ||
![]() |
6d2e7f9fbd | ||
![]() |
1016c87c60 | ||
![]() |
dbda1d75f9 | ||
![]() |
a9c25b49b9 | ||
![]() |
fbff95345c | ||
![]() |
8f4e65d392 | ||
![]() |
3a2cb51f8d | ||
![]() |
6c9cfed49f | ||
![]() |
257cab1061 | ||
![]() |
5f6577a24c | ||
![]() |
f8d6f0fae4 | ||
![]() |
5b7eeb6ac1 | ||
![]() |
191feed920 | ||
![]() |
13c1763277 | ||
![]() |
ac9085c7a4 | ||
![]() |
8beb93b695 | ||
![]() |
4f76e66cc0 | ||
![]() |
1ebd13027c | ||
![]() |
04beef5e36 | ||
![]() |
0a578c5847 | ||
![]() |
41924d8ec6 | ||
![]() |
6ff1a6fecc | ||
![]() |
05eb6e15a5 | ||
![]() |
6d01728d54 | ||
![]() |
2550bff4e9 | ||
![]() |
6e003907fb | ||
![]() |
f6c15dc990 | ||
![]() |
1061769144 | ||
![]() |
1edfec08e1 | ||
![]() |
239cad9b47 | ||
![]() |
8d7c175d70 | ||
![]() |
795bbefba6 | ||
![]() |
e1b34eaa33 | ||
![]() |
91d5d2f1eb |
38
.github/workflows/ci.yaml
vendored
38
.github/workflows/ci.yaml
vendored
@@ -26,20 +26,14 @@ jobs:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Setup Node
|
||||
id: setup-node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- uses: actions/cache@v4.2.0
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: "node_modules"
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}
|
||||
- name: Install dependencies
|
||||
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
||||
run: yarn install --immutable
|
||||
- name: Check for duplicate dependencies
|
||||
run: yarn dedupe --check
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
|
||||
- name: Setup lint cache
|
||||
@@ -66,19 +60,11 @@ jobs:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Setup Node
|
||||
id: setup-node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- uses: actions/cache@v4.2.0
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: "node_modules"
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}
|
||||
- name: Install dependencies
|
||||
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
||||
run: yarn install --immutable
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
|
||||
@@ -92,26 +78,18 @@ jobs:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Setup Node
|
||||
id: setup-node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- uses: actions/cache@v4.2.0
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: "node_modules"
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}
|
||||
- name: Install dependencies
|
||||
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
||||
run: yarn install --immutable
|
||||
- name: Build Application
|
||||
run: ./node_modules/.bin/gulp build-app
|
||||
env:
|
||||
IS_TEST: "true"
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@v4.4.3
|
||||
uses: actions/upload-artifact@v4.5.0
|
||||
with:
|
||||
name: frontend-bundle-stats
|
||||
path: build/stats/*.json
|
||||
@@ -124,26 +102,18 @@ jobs:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Setup Node
|
||||
id: setup-node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- uses: actions/cache@v4.2.0
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: "node_modules"
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}
|
||||
- name: Install dependencies
|
||||
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
||||
run: yarn install --immutable
|
||||
- name: Build Application
|
||||
run: ./node_modules/.bin/gulp build-hassio
|
||||
env:
|
||||
IS_TEST: "true"
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@v4.4.3
|
||||
uses: actions/upload-artifact@v4.5.0
|
||||
with:
|
||||
name: supervisor-bundle-stats
|
||||
path: build/stats/*.json
|
||||
|
4
.github/workflows/nightly.yaml
vendored
4
.github/workflows/nightly.yaml
vendored
@@ -57,14 +57,14 @@ jobs:
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4.4.3
|
||||
uses: actions/upload-artifact@v4.5.0
|
||||
with:
|
||||
name: wheels
|
||||
path: dist/home_assistant_frontend*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@v4.4.3
|
||||
uses: actions/upload-artifact@v4.5.0
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
|
6
.github/workflows/release.yaml
vendored
6
.github/workflows/release.yaml
vendored
@@ -25,14 +25,14 @@ jobs:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@master
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
with:
|
||||
|
@@ -5,7 +5,7 @@ import paths from "../paths.cjs";
|
||||
const POLYFILL_DIR = join(paths.polymer_dir, "src/resources/polyfills");
|
||||
|
||||
// List of polyfill keys with supported browser targets for the functionality
|
||||
const PolyfillSupport = {
|
||||
const polyfillSupport = {
|
||||
// Note states and shadowRoot properties should be supported.
|
||||
"element-internals": {
|
||||
android: 90,
|
||||
@@ -18,17 +18,6 @@ const PolyfillSupport = {
|
||||
safari: 17.4,
|
||||
samsung: 15.0,
|
||||
},
|
||||
"element-append": {
|
||||
android: 54,
|
||||
chrome: 54,
|
||||
edge: 17,
|
||||
firefox: 49,
|
||||
ios: 10.0,
|
||||
opera: 41,
|
||||
opera_mobile: 41,
|
||||
safari: 10.0,
|
||||
samsung: 6.0,
|
||||
},
|
||||
"element-getattributenames": {
|
||||
android: 61,
|
||||
chrome: 61,
|
||||
@@ -51,27 +40,18 @@ const PolyfillSupport = {
|
||||
safari: 12.0,
|
||||
samsung: 10.0,
|
||||
},
|
||||
fetch: {
|
||||
android: 42,
|
||||
chrome: 42,
|
||||
edge: 14,
|
||||
firefox: 39,
|
||||
ios: 10.3,
|
||||
opera: 29,
|
||||
opera_mobile: 29,
|
||||
safari: 10.1,
|
||||
samsung: 4.0,
|
||||
},
|
||||
// FormatJS polyfill detects fix for https://bugs.chromium.org/p/v8/issues/detail?id=10682,
|
||||
// so adjusted to several months after that was marked fixed
|
||||
"intl-getcanonicallocales": {
|
||||
android: 54,
|
||||
chrome: 54,
|
||||
edge: 16,
|
||||
android: 90,
|
||||
chrome: 90,
|
||||
edge: 90,
|
||||
firefox: 48,
|
||||
ios: 10.3,
|
||||
opera: 41,
|
||||
opera_mobile: 41,
|
||||
opera: 76,
|
||||
opera_mobile: 64,
|
||||
safari: 10.1,
|
||||
samsung: 6.0,
|
||||
samsung: 15.0,
|
||||
},
|
||||
"intl-locale": {
|
||||
android: 74,
|
||||
@@ -87,17 +67,6 @@ const PolyfillSupport = {
|
||||
"intl-other": {
|
||||
// Not specified (i.e. always try polyfill) since compatibility depends on supported locales
|
||||
},
|
||||
proxy: {
|
||||
android: 49,
|
||||
chrome: 49,
|
||||
edge: 12,
|
||||
firefox: 18,
|
||||
ios: 10.0,
|
||||
opera: 36,
|
||||
opera_mobile: 36,
|
||||
safari: 10.0,
|
||||
samsung: 5.0,
|
||||
},
|
||||
"resize-observer": {
|
||||
android: 64,
|
||||
chrome: 64,
|
||||
@@ -115,8 +84,6 @@ const PolyfillSupport = {
|
||||
// corresponding polyfill key and actual module to import
|
||||
const polyfillMap = {
|
||||
global: {
|
||||
fetch: { key: "fetch", module: "unfetch/polyfill" },
|
||||
Proxy: { key: "proxy", module: "proxy-polyfill" },
|
||||
ResizeObserver: {
|
||||
key: "resize-observer",
|
||||
module: join(POLYFILL_DIR, "resize-observer.ts"),
|
||||
@@ -128,7 +95,7 @@ const polyfillMap = {
|
||||
module: "element-internals-polyfill",
|
||||
},
|
||||
...Object.fromEntries(
|
||||
["append", "getAttributeNames", "toggleAttribute"].map((prop) => {
|
||||
["getAttributeNames", "toggleAttribute"].map((prop) => {
|
||||
const key = `element-${prop.toLowerCase()}`;
|
||||
return [prop, { key, module: join(POLYFILL_DIR, `${key}.ts`) }];
|
||||
})
|
||||
@@ -168,7 +135,7 @@ export default defineProvider(
|
||||
const resolvePolyfill = createMetaResolver(polyfillMap);
|
||||
return {
|
||||
name: "custom-polyfill",
|
||||
polyfills: PolyfillSupport,
|
||||
polyfills: polyfillSupport,
|
||||
usageGlobal(meta, utils) {
|
||||
const polyfill = resolvePolyfill(meta);
|
||||
if (polyfill && shouldInjectPolyfill(polyfill.desc.key)) {
|
||||
|
@@ -13,28 +13,41 @@ const brotliOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
const compressDistBrotli = (rootDir, modernDir, compressServiceWorker = true) =>
|
||||
const compressModern = (rootDir, modernDir) =>
|
||||
gulp
|
||||
.src([`${modernDir}/**/${filesGlob}`, `${rootDir}/sw-modern.js`], {
|
||||
base: rootDir,
|
||||
allowEmpty: true,
|
||||
})
|
||||
.pipe(brotli(brotliOptions))
|
||||
.pipe(gulp.dest(rootDir));
|
||||
|
||||
const compressOther = (rootDir, modernDir) =>
|
||||
gulp
|
||||
.src(
|
||||
[
|
||||
`${modernDir}/**/${filesGlob}`,
|
||||
compressServiceWorker ? `${rootDir}/sw-modern.js` : undefined,
|
||||
].filter(Boolean),
|
||||
{
|
||||
base: rootDir,
|
||||
}
|
||||
`${rootDir}/**/${filesGlob}`,
|
||||
`!${modernDir}/**/${filesGlob}`,
|
||||
`!${rootDir}/{sw-modern,service_worker}.js`,
|
||||
`${rootDir}/{authorize,onboarding}.html`,
|
||||
],
|
||||
{ base: rootDir, allowEmpty: true }
|
||||
)
|
||||
.pipe(brotli(brotliOptions))
|
||||
.pipe(gulp.dest(rootDir));
|
||||
|
||||
const compressAppBrotli = () =>
|
||||
compressDistBrotli(paths.app_output_root, paths.app_output_latest);
|
||||
const compressHassioBrotli = () =>
|
||||
compressDistBrotli(
|
||||
paths.hassio_output_root,
|
||||
paths.hassio_output_latest,
|
||||
false
|
||||
);
|
||||
const compressAppModern = () =>
|
||||
compressModern(paths.app_output_root, paths.app_output_latest);
|
||||
const compressHassioModern = () =>
|
||||
compressModern(paths.hassio_output_root, paths.hassio_output_latest);
|
||||
|
||||
gulp.task("compress-app", compressAppBrotli);
|
||||
gulp.task("compress-hassio", compressHassioBrotli);
|
||||
const compressAppOther = () =>
|
||||
compressOther(paths.app_output_root, paths.app_output_latest);
|
||||
const compressHassioOther = () =>
|
||||
compressOther(paths.hassio_output_root, paths.hassio_output_latest);
|
||||
|
||||
gulp.task("compress-app", gulp.parallel(compressAppModern, compressAppOther));
|
||||
gulp.task(
|
||||
"compress-hassio",
|
||||
gulp.parallel(compressHassioModern, compressHassioOther)
|
||||
);
|
||||
|
@@ -67,12 +67,6 @@ function copyPolyfills(staticDir) {
|
||||
);
|
||||
}
|
||||
|
||||
function copyLoaderJS(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
copyFileDir(npmPath("systemjs/dist/s.min.js"), staticPath("js"));
|
||||
copyFileDir(npmPath("systemjs/dist/s.min.js.map"), staticPath("js"));
|
||||
}
|
||||
|
||||
function copyFonts(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
// Local fonts
|
||||
@@ -140,8 +134,6 @@ gulp.task("copy-static-app", async () => {
|
||||
const staticDir = paths.app_output_static;
|
||||
// Basic static files
|
||||
fs.copySync(polyPath("public"), paths.app_output_root);
|
||||
|
||||
copyLoaderJS(staticDir);
|
||||
copyPolyfills(staticDir);
|
||||
copyFonts(staticDir);
|
||||
copyTranslations(staticDir);
|
||||
@@ -164,8 +156,6 @@ gulp.task("copy-static-demo", async () => {
|
||||
);
|
||||
// Copy demo static files
|
||||
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
|
||||
|
||||
copyLoaderJS(paths.demo_output_static);
|
||||
copyPolyfills(paths.demo_output_static);
|
||||
copyMapPanel(paths.demo_output_static);
|
||||
copyFonts(paths.demo_output_static);
|
||||
@@ -179,8 +169,6 @@ gulp.task("copy-static-cast", async () => {
|
||||
fs.copySync(polyPath("public/static"), paths.cast_output_static);
|
||||
// Copy cast static files
|
||||
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
|
||||
|
||||
copyLoaderJS(paths.cast_output_static);
|
||||
copyPolyfills(paths.cast_output_static);
|
||||
copyMapPanel(paths.cast_output_static);
|
||||
copyFonts(paths.cast_output_static);
|
||||
|
@@ -14,6 +14,7 @@ import "../../../../src/panels/lovelace/views/hui-view";
|
||||
import "../../../../src/panels/lovelace/views/hui-view-container";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import "./hc-launch-screen";
|
||||
import "../../../../src/panels/lovelace/views/hui-view-background";
|
||||
|
||||
(window as any).loadCardHelpers = () =>
|
||||
import("../../../../src/panels/lovelace/custom-card-helpers");
|
||||
@@ -57,11 +58,8 @@ class HcLovelace extends LitElement {
|
||||
const background = viewConfig.background || this.lovelaceConfig.background;
|
||||
|
||||
return html`
|
||||
<hui-view-container
|
||||
.hass=${this.hass}
|
||||
.background=${background}
|
||||
.theme=${viewConfig.theme}
|
||||
>
|
||||
<hui-view-container .hass=${this.hass} .theme=${viewConfig.theme}>
|
||||
<hui-view-background .background=${background}> </hui-view-background>
|
||||
<hui-view
|
||||
.hass=${this.hass}
|
||||
.lovelace=${lovelace}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { mdiFolderUpload } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import "../../../src/components/ha-circular-progress";
|
||||
import "../../../src/components/ha-file-upload";
|
||||
@@ -10,10 +10,12 @@ import { uploadBackup } from "../../../src/data/hassio/backup";
|
||||
import { extractApiErrorMessage } from "../../../src/data/hassio/common";
|
||||
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"backup-uploaded": { backup: HassioBackup };
|
||||
"backup-cleared": void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +23,8 @@ declare global {
|
||||
export class HassioUploadBackup extends LitElement {
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
|
||||
@state() public value: string | null = null;
|
||||
|
||||
@state() private _uploading = false;
|
||||
@@ -32,13 +36,26 @@ export class HassioUploadBackup extends LitElement {
|
||||
.uploading=${this._uploading}
|
||||
.icon=${mdiFolderUpload}
|
||||
accept="application/x-tar"
|
||||
label="Upload backup"
|
||||
supports="Supports .TAR files"
|
||||
.label=${this.localize?.(
|
||||
"ui.panel.page-onboarding.restore.upload_backup"
|
||||
) || "Upload backup"}
|
||||
.supports=${this.localize?.(
|
||||
"ui.panel.page-onboarding.restore.upload_supports"
|
||||
) || "Supports .TAR files"}
|
||||
.secondary=${this.localize?.(
|
||||
"ui.panel.page-onboarding.restore.upload_drop"
|
||||
) || "Or drop your file here"}
|
||||
@file-picked=${this._uploadFile}
|
||||
@files-cleared=${this._clear}
|
||||
></ha-file-upload>
|
||||
`;
|
||||
}
|
||||
|
||||
private _clear() {
|
||||
this.value = null;
|
||||
fireEvent(this, "backup-cleared");
|
||||
}
|
||||
|
||||
private async _uploadFile(ev) {
|
||||
const file = ev.detail.files[0];
|
||||
|
||||
|
@@ -65,7 +65,7 @@ const _computeAddons = (addons): AddonCheckboxItem[] =>
|
||||
|
||||
@customElement("supervisor-backup-content")
|
||||
export class SupervisorBackupContent extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
|
||||
@@ -186,12 +186,13 @@ export class SupervisorBackupContent extends LitElement {
|
||||
.iconPath=${mdiHomeAssistant}
|
||||
.version=${this.backup
|
||||
? this.backup.homeassistant
|
||||
: this.hass.config.version}
|
||||
: this.hass?.config.version}
|
||||
>
|
||||
</supervisor-formfield-label>`}
|
||||
>
|
||||
<ha-checkbox
|
||||
.checked=${this.homeAssistant}
|
||||
.checked=${this.onboarding || this.homeAssistant}
|
||||
.disabled=${this.onboarding}
|
||||
@change=${this._toggleHomeAssistant}
|
||||
>
|
||||
</ha-checkbox>
|
||||
@@ -334,7 +335,7 @@ export class SupervisorBackupContent extends LitElement {
|
||||
| HassioFullBackupCreateParams {
|
||||
const data: any = {};
|
||||
|
||||
if (!this.backup) {
|
||||
if (!this.backup && this.hass) {
|
||||
data.name =
|
||||
this.backupName ||
|
||||
formatDate(new Date(), this.hass.locale, this.hass.config);
|
||||
@@ -364,7 +365,9 @@ export class SupervisorBackupContent extends LitElement {
|
||||
if (folders?.length) {
|
||||
data.folders = folders;
|
||||
}
|
||||
data.homeassistant = this.homeAssistant;
|
||||
|
||||
// onboarding needs at least homeassistant to restore
|
||||
data.homeassistant = this.onboarding || this.homeAssistant;
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -386,6 +389,7 @@ export class SupervisorBackupContent extends LitElement {
|
||||
.iconPath=${section === "addons" ? mdiPuzzle : mdiFolder}
|
||||
.imageUrl=${section === "addons" &&
|
||||
!this.onboarding &&
|
||||
this.hass &&
|
||||
atLeastVersion(this.hass.config.version, 0, 105) &&
|
||||
addons?.get(item.slug)?.icon
|
||||
? `/api/hassio/addons/${item.slug}/icon`
|
||||
|
@@ -8,9 +8,11 @@ import { atLeastVersion } from "../../../../src/common/config/version";
|
||||
import { fireEvent } from "../../../../src/common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../src/common/dom/stop_propagation";
|
||||
import { slugify } from "../../../../src/common/string/slugify";
|
||||
import "../../../../src/components/ha-dialog";
|
||||
import "../../../../src/components/ha-md-dialog";
|
||||
import "../../../../src/components/ha-dialog-header";
|
||||
import "../../../../src/components/buttons/ha-progress-button";
|
||||
import "../../../../src/components/ha-alert";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-button-menu";
|
||||
import "../../../../src/components/ha-header-bar";
|
||||
import "../../../../src/components/ha-icon-button";
|
||||
@@ -19,6 +21,7 @@ import type { HassioBackupDetail } from "../../../../src/data/hassio/backup";
|
||||
import {
|
||||
fetchHassioBackupInfo,
|
||||
removeBackup,
|
||||
restoreBackup,
|
||||
} from "../../../../src/data/hassio/backup";
|
||||
import { extractApiErrorMessage } from "../../../../src/data/hassio/common";
|
||||
import {
|
||||
@@ -33,6 +36,7 @@ import "../../components/supervisor-backup-content";
|
||||
import type { SupervisorBackupContent } from "../../components/supervisor-backup-content";
|
||||
import type { HassioBackupDialogParams } from "./show-dialog-hassio-backup";
|
||||
import type { BackupOrRestoreKey } from "../../util/translations";
|
||||
import type { HaMdDialog } from "../../../../src/components/ha-md-dialog";
|
||||
|
||||
@customElement("dialog-hassio-backup")
|
||||
class HassioBackupDialog
|
||||
@@ -52,13 +56,20 @@ class HassioBackupDialog
|
||||
@query("supervisor-backup-content")
|
||||
private _backupContent!: SupervisorBackupContent;
|
||||
|
||||
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||
|
||||
public async showDialog(dialogParams: HassioBackupDialogParams) {
|
||||
this._backup = await fetchHassioBackupInfo(this.hass, dialogParams.slug);
|
||||
this._dialogParams = dialogParams;
|
||||
this._backup = await fetchHassioBackupInfo(this.hass, dialogParams.slug);
|
||||
if (!this._backup) {
|
||||
this._error = this._localize("no_backup_found");
|
||||
} else if (this._dialogParams.onboarding && !this._backup.homeassistant) {
|
||||
this._error = this._localize("restore_no_home_assistant");
|
||||
}
|
||||
this._restoringBackup = false;
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
private _dialogClosed(): void {
|
||||
this._backup = undefined;
|
||||
this._dialogParams = undefined;
|
||||
this._restoringBackup = false;
|
||||
@@ -66,6 +77,10 @@ class HassioBackupDialog
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._dialog?.close();
|
||||
}
|
||||
|
||||
private _localize(key: BackupOrRestoreKey) {
|
||||
return (
|
||||
this._dialogParams!.supervisor?.localize(`backup.${key}`) ||
|
||||
@@ -78,100 +93,80 @@ class HassioBackupDialog
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-dialog
|
||||
<ha-md-dialog
|
||||
open
|
||||
scrimClickAction
|
||||
@closed=${this.closeDialog}
|
||||
.heading=${this._backup.name}
|
||||
.disableCancelAction=${!this._error}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<div slot="heading">
|
||||
<ha-header-bar>
|
||||
<span slot="title">${this._backup.name}</span>
|
||||
<ha-icon-button
|
||||
.label=${this._localize("close")}
|
||||
.path=${mdiClose}
|
||||
slot="actionItems"
|
||||
dialogAction="cancel"
|
||||
></ha-icon-button>
|
||||
</ha-header-bar>
|
||||
<ha-dialog-header slot="headline">
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
.label=${this._localize("close")}
|
||||
.path=${mdiClose}
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._restoringBackup}
|
||||
></ha-icon-button>
|
||||
<span slot="title" .title=${this._backup.name}
|
||||
>${this._backup.name}</span
|
||||
>
|
||||
${!this._dialogParams.onboarding && this._dialogParams.supervisor
|
||||
? html`<ha-button-menu
|
||||
slot="actionItems"
|
||||
fixed
|
||||
@action=${this._handleMenuAction}
|
||||
@closed=${stopPropagation}
|
||||
>
|
||||
<ha-icon-button
|
||||
.label=${this._dialogParams.supervisor.localize(
|
||||
"backup.more_actions"
|
||||
)}
|
||||
.path=${mdiDotsVertical}
|
||||
slot="trigger"
|
||||
></ha-icon-button>
|
||||
<mwc-list-item
|
||||
>${this._dialogParams.supervisor.localize(
|
||||
"backup.download_backup"
|
||||
)}</mwc-list-item
|
||||
>
|
||||
<mwc-list-item class="error"
|
||||
>${this._dialogParams.supervisor.localize(
|
||||
"backup.delete_backup_title"
|
||||
)}</mwc-list-item
|
||||
>
|
||||
</ha-button-menu>`
|
||||
: nothing}
|
||||
</ha-dialog-header>
|
||||
<div slot="content">
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: this._restoringBackup
|
||||
? html`<div class="loading">
|
||||
<ha-circular-progress indeterminate></ha-circular-progress>
|
||||
</div>`
|
||||
: html`
|
||||
<supervisor-backup-content
|
||||
.hass=${this.hass}
|
||||
.supervisor=${this._dialogParams.supervisor}
|
||||
.backup=${this._backup}
|
||||
.onboarding=${this._dialogParams.onboarding || false}
|
||||
.localize=${this._dialogParams.localize}
|
||||
dialogInitialFocus
|
||||
>
|
||||
</supervisor-backup-content>
|
||||
`}
|
||||
</div>
|
||||
${this._restoringBackup
|
||||
? html`<ha-circular-progress indeterminate></ha-circular-progress>`
|
||||
: html`
|
||||
<supervisor-backup-content
|
||||
.hass=${this.hass}
|
||||
.supervisor=${this._dialogParams.supervisor}
|
||||
.backup=${this._backup}
|
||||
.onboarding=${this._dialogParams.onboarding || false}
|
||||
.localize=${this._dialogParams.localize}
|
||||
dialogInitialFocus
|
||||
>
|
||||
</supervisor-backup-content>
|
||||
`}
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
|
||||
<mwc-button
|
||||
.disabled=${this._restoringBackup}
|
||||
slot="secondaryAction"
|
||||
@click=${this._restoreClicked}
|
||||
>
|
||||
${this._localize("restore")}
|
||||
</mwc-button>
|
||||
|
||||
${!this._dialogParams.onboarding && this._dialogParams.supervisor
|
||||
? html`<ha-button-menu
|
||||
fixed
|
||||
slot="primaryAction"
|
||||
@action=${this._handleMenuAction}
|
||||
@closed=${stopPropagation}
|
||||
>
|
||||
<ha-icon-button
|
||||
.label=${this._dialogParams.supervisor.localize(
|
||||
"backup.more_actions"
|
||||
)}
|
||||
.path=${mdiDotsVertical}
|
||||
slot="trigger"
|
||||
></ha-icon-button>
|
||||
<mwc-list-item
|
||||
>${this._dialogParams.supervisor.localize(
|
||||
"backup.download_backup"
|
||||
)}</mwc-list-item
|
||||
>
|
||||
<mwc-list-item class="error"
|
||||
>${this._dialogParams.supervisor.localize(
|
||||
"backup.delete_backup_title"
|
||||
)}</mwc-list-item
|
||||
>
|
||||
</ha-button-menu>`
|
||||
: nothing}
|
||||
</ha-dialog>
|
||||
<div slot="actions">
|
||||
<ha-button
|
||||
.disabled=${this._restoringBackup || !!this._error}
|
||||
@click=${this._restoreClicked}
|
||||
>
|
||||
${this._localize("restore")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-md-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-circular-progress {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
ha-header-bar {
|
||||
--mdc-theme-on-primary: var(--primary-text-color);
|
||||
--mdc-theme-primary: var(--mdc-theme-surface);
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
ha-icon-button {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
private _handleMenuAction(ev: CustomEvent<ActionDetail>) {
|
||||
switch (ev.detail.index) {
|
||||
case 0:
|
||||
@@ -184,18 +179,9 @@ class HassioBackupDialog
|
||||
}
|
||||
|
||||
private async _restoreClicked() {
|
||||
const backupDetails = this._backupContent.backupDetails();
|
||||
this._restoringBackup = true;
|
||||
this._dialogParams?.onRestoring?.();
|
||||
if (this._backupContent.backupType === "full") {
|
||||
await this._fullRestoreClicked(backupDetails);
|
||||
} else {
|
||||
await this._partialRestoreClicked(backupDetails);
|
||||
}
|
||||
this._restoringBackup = false;
|
||||
}
|
||||
const backupDetails = this._backupContent.backupDetails();
|
||||
|
||||
private async _partialRestoreClicked(backupDetails) {
|
||||
const supervisor = this._dialogParams?.supervisor;
|
||||
if (supervisor !== undefined && supervisor.info.state !== "running") {
|
||||
await showAlertDialog(this, {
|
||||
@@ -204,91 +190,45 @@ class HassioBackupDialog
|
||||
state: supervisor.info.state,
|
||||
}),
|
||||
});
|
||||
this._restoringBackup = false;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this._localize("confirm_restore_partial_backup_title"),
|
||||
text: this._localize("confirm_restore_partial_backup_text"),
|
||||
title: this._localize(
|
||||
this._backupContent.backupType === "full"
|
||||
? "confirm_restore_full_backup_title"
|
||||
: "confirm_restore_partial_backup_title"
|
||||
),
|
||||
text: this._localize(
|
||||
this._backupContent.backupType === "full"
|
||||
? "confirm_restore_full_backup_text"
|
||||
: "confirm_restore_partial_backup_text"
|
||||
),
|
||||
confirmText: this._localize("restore"),
|
||||
dismissText: this._localize("cancel"),
|
||||
}))
|
||||
) {
|
||||
this._restoringBackup = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._dialogParams?.onboarding) {
|
||||
try {
|
||||
await this.hass!.callApi(
|
||||
"POST",
|
||||
|
||||
`hassio/${
|
||||
atLeastVersion(this.hass!.config.version, 2021, 9)
|
||||
? "backups"
|
||||
: "snapshots"
|
||||
}/${this._backup!.slug}/restore/partial`,
|
||||
backupDetails
|
||||
);
|
||||
this.closeDialog();
|
||||
} catch (error: any) {
|
||||
this._error = error.body.message;
|
||||
}
|
||||
} else {
|
||||
this._dialogParams?.onRestoring?.();
|
||||
await fetch(`/api/hassio/backups/${this._backup!.slug}/restore/partial`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(backupDetails),
|
||||
});
|
||||
this.closeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
private async _fullRestoreClicked(backupDetails) {
|
||||
const supervisor = this._dialogParams?.supervisor;
|
||||
if (supervisor !== undefined && supervisor.info.state !== "running") {
|
||||
await showAlertDialog(this, {
|
||||
title: supervisor.localize("backup.could_not_restore"),
|
||||
text: supervisor.localize("backup.restore_blocked_not_running", {
|
||||
state: supervisor.info.state,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this._localize("confirm_restore_full_backup_title"),
|
||||
text: this._localize("confirm_restore_full_backup_text"),
|
||||
confirmText: this._localize("restore"),
|
||||
dismissText: this._localize("cancel"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._dialogParams?.onboarding) {
|
||||
this.hass!.callApi(
|
||||
"POST",
|
||||
`hassio/${
|
||||
atLeastVersion(this.hass!.config.version, 2021, 9)
|
||||
? "backups"
|
||||
: "snapshots"
|
||||
}/${this._backup!.slug}/restore/full`,
|
||||
backupDetails
|
||||
).then(
|
||||
() => {
|
||||
this.closeDialog();
|
||||
},
|
||||
(error) => {
|
||||
this._error = error.body.message;
|
||||
}
|
||||
try {
|
||||
await restoreBackup(
|
||||
this.hass,
|
||||
this._backupContent.backupType,
|
||||
this._backup!.slug,
|
||||
backupDetails,
|
||||
!!this.hass && atLeastVersion(this.hass.config.version, 2021, 9)
|
||||
);
|
||||
} else {
|
||||
|
||||
this._dialogParams?.onRestoring?.();
|
||||
fetch(`/api/hassio/backups/${this._backup!.slug}/restore/full`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(backupDetails),
|
||||
});
|
||||
this.closeDialog();
|
||||
} catch (error: any) {
|
||||
this._error =
|
||||
error?.body?.message || this._localize("restore_start_failed");
|
||||
} finally {
|
||||
this._restoringBackup = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +301,36 @@ class HassioBackupDialog
|
||||
private get _computeName() {
|
||||
return this._backup
|
||||
? this._backup.name || this._backup.slug
|
||||
: "Unnamed backup";
|
||||
: this._localize("unnamed_backup");
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-circular-progress {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
ha-header-bar {
|
||||
--mdc-theme-on-primary: var(--primary-text-color);
|
||||
--mdc-theme-primary: var(--mdc-theme-surface);
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
ha-icon-button {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.loading {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
64
package.json
64
package.json
@@ -27,24 +27,24 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.26.0",
|
||||
"@braintree/sanitize-url": "7.1.0",
|
||||
"@codemirror/autocomplete": "6.18.3",
|
||||
"@braintree/sanitize-url": "7.1.1",
|
||||
"@codemirror/autocomplete": "6.18.4",
|
||||
"@codemirror/commands": "6.7.1",
|
||||
"@codemirror/language": "6.10.6",
|
||||
"@codemirror/language": "6.10.7",
|
||||
"@codemirror/legacy-modes": "6.4.2",
|
||||
"@codemirror/search": "6.5.8",
|
||||
"@codemirror/state": "6.4.1",
|
||||
"@codemirror/view": "6.35.2",
|
||||
"@codemirror/state": "6.5.0",
|
||||
"@codemirror/view": "6.36.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "6.16.6",
|
||||
"@formatjs/intl-displaynames": "6.8.6",
|
||||
"@formatjs/intl-durationformat": "0.6.5",
|
||||
"@formatjs/intl-getcanonicallocales": "2.5.3",
|
||||
"@formatjs/intl-listformat": "7.7.6",
|
||||
"@formatjs/intl-locale": "4.2.6",
|
||||
"@formatjs/intl-numberformat": "8.14.6",
|
||||
"@formatjs/intl-pluralrules": "5.3.6",
|
||||
"@formatjs/intl-relativetimeformat": "11.4.6",
|
||||
"@formatjs/intl-datetimeformat": "6.17.1",
|
||||
"@formatjs/intl-displaynames": "6.8.8",
|
||||
"@formatjs/intl-durationformat": "0.7.1",
|
||||
"@formatjs/intl-getcanonicallocales": "2.5.4",
|
||||
"@formatjs/intl-listformat": "7.7.8",
|
||||
"@formatjs/intl-locale": "4.2.8",
|
||||
"@formatjs/intl-numberformat": "8.15.1",
|
||||
"@formatjs/intl-pluralrules": "5.4.1",
|
||||
"@formatjs/intl-relativetimeformat": "11.4.8",
|
||||
"@fullcalendar/core": "6.1.15",
|
||||
"@fullcalendar/daygrid": "6.1.15",
|
||||
"@fullcalendar/interaction": "6.1.15",
|
||||
@@ -91,8 +91,8 @@
|
||||
"@polymer/polymer": "3.5.2",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@vaadin/combo-box": "24.5.5",
|
||||
"@vaadin/vaadin-themable-mixin": "24.5.5",
|
||||
"@vaadin/combo-box": "24.6.0",
|
||||
"@vaadin/vaadin-themable-mixin": "24.6.0",
|
||||
"@vibrant/color": "3.2.1-alpha.1",
|
||||
"@vibrant/core": "3.2.1-alpha.1",
|
||||
"@vibrant/quantizer-mmcq": "3.2.1-alpha.1",
|
||||
@@ -102,6 +102,7 @@
|
||||
"app-datepicker": "5.1.1",
|
||||
"barcode-detector": "2.3.1",
|
||||
"chart.js": "4.4.7",
|
||||
"chartjs-plugin-zoom": "2.2.0",
|
||||
"color-name": "2.0.0",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.39.0",
|
||||
@@ -117,17 +118,16 @@
|
||||
"hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch",
|
||||
"home-assistant-js-websocket": "9.4.0",
|
||||
"idb-keyval": "6.2.1",
|
||||
"intl-messageformat": "10.7.8",
|
||||
"intl-messageformat": "10.7.10",
|
||||
"js-yaml": "4.1.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"lit": "2.8.0",
|
||||
"lit-html": "2.8.0",
|
||||
"luxon": "3.5.0",
|
||||
"marked": "15.0.3",
|
||||
"marked": "15.0.4",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "3.2.1-alpha.1",
|
||||
"proxy-polyfill": "0.3.2",
|
||||
"punycode": "2.3.1",
|
||||
"qr-scanner": "1.4.2",
|
||||
"qrcode": "1.5.4",
|
||||
@@ -139,8 +139,7 @@
|
||||
"tinykeys": "3.0.0",
|
||||
"tsparticles-engine": "2.12.0",
|
||||
"tsparticles-preset-links": "2.12.0",
|
||||
"ua-parser-js": "1.0.39",
|
||||
"unfetch": "5.0.0",
|
||||
"ua-parser-js": "1.0.40",
|
||||
"vis-data": "7.1.9",
|
||||
"vis-network": "9.1.9",
|
||||
"vue": "2.7.16",
|
||||
@@ -162,13 +161,13 @@
|
||||
"@babel/preset-env": "7.26.0",
|
||||
"@babel/preset-typescript": "7.26.0",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.17.0",
|
||||
"@lokalise/node-api": "12.8.0",
|
||||
"@lokalise/node-api": "13.0.0",
|
||||
"@octokit/auth-oauth-device": "7.1.1",
|
||||
"@octokit/plugin-retry": "7.1.2",
|
||||
"@octokit/rest": "21.0.2",
|
||||
"@rsdoctor/rspack-plugin": "0.4.11",
|
||||
"@rspack/cli": "1.1.5",
|
||||
"@rspack/core": "1.1.5",
|
||||
"@rsdoctor/rspack-plugin": "0.4.12",
|
||||
"@rspack/cli": "1.1.8",
|
||||
"@rspack/core": "1.1.8",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.20",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -182,7 +181,6 @@
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/mocha": "10.0.10",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/serve-handler": "6.1.4",
|
||||
"@types/sortablejs": "1.15.8",
|
||||
"@types/tar": "6.1.13",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
@@ -194,11 +192,11 @@
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.3",
|
||||
"del": "8.0.0",
|
||||
"eslint": "9.16.0",
|
||||
"eslint": "9.17.0",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-airbnb-typescript": "18.0.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-import-resolver-webpack": "0.13.9",
|
||||
"eslint-import-resolver-webpack": "0.13.10",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-lit": "1.15.0",
|
||||
"eslint-plugin-lit-a11y": "4.1.4",
|
||||
@@ -215,22 +213,18 @@
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "25.0.1",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "15.2.10",
|
||||
"lint-staged": "15.2.11",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.5.0",
|
||||
"magic-string": "0.30.14",
|
||||
"map-stream": "0.0.7",
|
||||
"object-hash": "3.0.0",
|
||||
"open": "10.1.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.4.2",
|
||||
"rspack-manifest-plugin": "5.0.2",
|
||||
"serve-handler": "6.1.6",
|
||||
"sinon": "19.0.2",
|
||||
"systemjs": "6.15.1",
|
||||
"tar": "7.4.3",
|
||||
"terser-webpack-plugin": "5.3.10",
|
||||
"terser-webpack-plugin": "5.3.11",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "5.7.2",
|
||||
"vitest": "2.1.8",
|
||||
@@ -247,7 +241,7 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "1.6.3",
|
||||
"@fullcalendar/daygrid": "6.1.15",
|
||||
"globals": "15.13.0"
|
||||
"globals": "15.14.0"
|
||||
},
|
||||
"packageManager": "yarn@4.5.3"
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20241127.0"
|
||||
version = "20250102.0"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "The Home Assistant frontend"
|
||||
readme = "README.md"
|
||||
|
@@ -1,14 +1,19 @@
|
||||
type NonUndefined<T> = T extends undefined ? never : T;
|
||||
type NonNullUndefined<T> = T extends undefined
|
||||
? never
|
||||
: T extends null
|
||||
? never
|
||||
: T;
|
||||
|
||||
/**
|
||||
* Ensure that the input is an array or wrap it in an array
|
||||
* @param value - The value to ensure is an array
|
||||
*/
|
||||
export function ensureArray(value: undefined): undefined;
|
||||
export function ensureArray<T>(value: T | T[]): NonUndefined<T>[];
|
||||
export function ensureArray<T>(value: T | readonly T[]): NonUndefined<T>[];
|
||||
export function ensureArray(value: null): null;
|
||||
export function ensureArray<T>(value: T | T[]): NonNullUndefined<T>[];
|
||||
export function ensureArray<T>(value: T | readonly T[]): NonNullUndefined<T>[];
|
||||
export function ensureArray(value) {
|
||||
if (value === undefined || Array.isArray(value)) {
|
||||
if (value === undefined || value === null || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
return [value];
|
||||
|
@@ -90,9 +90,9 @@ export const lab2rgb = (
|
||||
x = Xn * lab_xyz(x);
|
||||
z = Zn * lab_xyz(z);
|
||||
|
||||
const r = xyz_rgb(3.2404542 * x - 1.5371385 * y - 0.4985314 * z); // D65 -> sRGB
|
||||
const g = xyz_rgb(-0.969266 * x + 1.8760108 * y + 0.041556 * z);
|
||||
const b_ = xyz_rgb(0.0556434 * x - 0.2040259 * y + 1.0572252 * z);
|
||||
const r = Math.round(xyz_rgb(3.2404542 * x - 1.5371385 * y - 0.4985314 * z)); // D65 -> sRGB
|
||||
const g = Math.round(xyz_rgb(-0.969266 * x + 1.8760108 * y + 0.041556 * z));
|
||||
const b_ = Math.round(xyz_rgb(0.0556434 * x - 0.2040259 * y + 1.0572252 * z));
|
||||
|
||||
return [r, g, b_];
|
||||
};
|
||||
|
@@ -8,9 +8,9 @@ export const temperature2rgb = (
|
||||
): [number, number, number] => {
|
||||
const value = temperature / 100;
|
||||
return [
|
||||
temperatureRed(value),
|
||||
temperatureGreen(value),
|
||||
temperatureBlue(value),
|
||||
Math.round(temperatureRed(value)),
|
||||
Math.round(temperatureGreen(value)),
|
||||
Math.round(temperatureBlue(value)),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -59,10 +59,10 @@ const matchMaxScale = (
|
||||
};
|
||||
|
||||
export const mired2kelvin = (miredTemperature: number) =>
|
||||
Math.floor(1000000 / miredTemperature);
|
||||
miredTemperature === 0 ? 1000000 : Math.floor(1000000 / miredTemperature);
|
||||
|
||||
export const kelvin2mired = (kelvintTemperature: number) =>
|
||||
Math.floor(1000000 / kelvintTemperature);
|
||||
export const kelvin2mired = (kelvinTemperature: number) =>
|
||||
kelvinTemperature === 0 ? 1000000 : Math.floor(1000000 / kelvinTemperature);
|
||||
|
||||
export const rgbww2rgb = (
|
||||
rgbww: [number, number, number, number, number],
|
||||
|
@@ -14,8 +14,8 @@ export const hexBlend = (c1: string, c2: string, blend = 50): string => {
|
||||
c1 = expandHex(c1);
|
||||
c2 = expandHex(c2);
|
||||
for (let i = 0; i <= 5; i += 2) {
|
||||
const h1 = parseInt(c1.substr(i, 2), 16);
|
||||
const h2 = parseInt(c2.substr(i, 2), 16);
|
||||
const h1 = parseInt(c1.substring(i, i + 2), 16);
|
||||
const h2 = parseInt(c2.substring(i, i + 2), 16);
|
||||
let hex = Math.floor(h2 + (h1 - h2) * (blend / 100)).toString(16);
|
||||
while (hex.length < 2) hex = "0" + hex;
|
||||
color += hex;
|
||||
|
@@ -1,12 +1,13 @@
|
||||
// From https://github.com/gka/chroma.js
|
||||
// Copyright (c) 2011-2019, Gregor Aisch
|
||||
|
||||
export const labDarken = (
|
||||
lab: [number, number, number],
|
||||
amount = 1
|
||||
): [number, number, number] => [lab[0] - 18 * amount, lab[1], lab[2]];
|
||||
export type LabColor = [number, number, number];
|
||||
|
||||
export const labBrighten = (
|
||||
lab: [number, number, number],
|
||||
amount = 1
|
||||
): [number, number, number] => labDarken(lab, -amount);
|
||||
export const labDarken = (lab: LabColor, amount = 1): LabColor => [
|
||||
lab[0] - 18 * amount,
|
||||
lab[1],
|
||||
lab[2],
|
||||
];
|
||||
|
||||
export const labBrighten = (lab: LabColor, amount = 1): LabColor =>
|
||||
labDarken(lab, -amount);
|
||||
|
@@ -8,20 +8,27 @@ export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
|
||||
!hideAdvancedPage(hass, page) &&
|
||||
isNotLoadedIntegration(hass, page);
|
||||
|
||||
const isLoadedIntegration = (hass: HomeAssistant, page: PageNavigation) =>
|
||||
export const isLoadedIntegration = (
|
||||
hass: HomeAssistant,
|
||||
page: PageNavigation
|
||||
) =>
|
||||
!page.component ||
|
||||
ensureArray(page.component).some((integration) =>
|
||||
isComponentLoaded(hass, integration)
|
||||
);
|
||||
|
||||
const isNotLoadedIntegration = (hass: HomeAssistant, page: PageNavigation) =>
|
||||
export const isNotLoadedIntegration = (
|
||||
hass: HomeAssistant,
|
||||
page: PageNavigation
|
||||
) =>
|
||||
!page.not_component ||
|
||||
!ensureArray(page.not_component).some((integration) =>
|
||||
isComponentLoaded(hass, integration)
|
||||
);
|
||||
|
||||
const isCore = (page: PageNavigation) => page.core;
|
||||
const isAdvancedPage = (page: PageNavigation) => page.advancedOnly;
|
||||
const userWantsAdvanced = (hass: HomeAssistant) => hass.userData?.showAdvanced;
|
||||
const hideAdvancedPage = (hass: HomeAssistant, page: PageNavigation) =>
|
||||
export const isCore = (page: PageNavigation) => page.core;
|
||||
export const isAdvancedPage = (page: PageNavigation) => page.advancedOnly;
|
||||
export const userWantsAdvanced = (hass: HomeAssistant) =>
|
||||
hass.userData?.showAdvanced;
|
||||
export const hideAdvancedPage = (hass: HomeAssistant, page: PageNavigation) =>
|
||||
isAdvancedPage(page) && !userWantsAdvanced(hass);
|
||||
|
@@ -1,202 +1,9 @@
|
||||
/** Constants to be used in the frontend. */
|
||||
|
||||
import {
|
||||
mdiAccount,
|
||||
mdiAirFilter,
|
||||
mdiAlert,
|
||||
mdiAngleAcute,
|
||||
mdiAppleSafari,
|
||||
mdiArrowLeftRight,
|
||||
mdiBell,
|
||||
mdiBookmark,
|
||||
mdiBrightness5,
|
||||
mdiBullhorn,
|
||||
mdiButtonPointer,
|
||||
mdiCalendar,
|
||||
mdiCalendarClock,
|
||||
mdiCarCoolantLevel,
|
||||
mdiCash,
|
||||
mdiChatSleep,
|
||||
mdiClipboardList,
|
||||
mdiClock,
|
||||
mdiCog,
|
||||
mdiCommentAlert,
|
||||
mdiCounter,
|
||||
mdiCurrentAc,
|
||||
mdiDatabase,
|
||||
mdiEarHearing,
|
||||
mdiEye,
|
||||
mdiFlash,
|
||||
mdiFlower,
|
||||
mdiFormatListBulleted,
|
||||
mdiFormatListCheckbox,
|
||||
mdiFormTextbox,
|
||||
mdiForumOutline,
|
||||
mdiGauge,
|
||||
mdiGoogleAssistant,
|
||||
mdiGoogleCirclesCommunities,
|
||||
mdiHomeAutomation,
|
||||
mdiImage,
|
||||
mdiImageFilterFrames,
|
||||
mdiLightbulb,
|
||||
mdiLightningBolt,
|
||||
mdiMapMarkerRadius,
|
||||
mdiMeterGas,
|
||||
mdiMicrophoneMessage,
|
||||
mdiMolecule,
|
||||
mdiMoleculeCo,
|
||||
mdiMoleculeCo2,
|
||||
mdiPalette,
|
||||
mdiPh,
|
||||
mdiPipe,
|
||||
mdiProgressClock,
|
||||
mdiRayVertex,
|
||||
mdiRemote,
|
||||
mdiRobot,
|
||||
mdiRobotMower,
|
||||
mdiRobotVacuum,
|
||||
mdiRoomService,
|
||||
mdiScriptText,
|
||||
mdiSineWave,
|
||||
mdiSpeakerMessage,
|
||||
mdiSpeedometer,
|
||||
mdiSunWireless,
|
||||
mdiThermometer,
|
||||
mdiThermometerLines,
|
||||
mdiThermostat,
|
||||
mdiTimerOutline,
|
||||
mdiToggleSwitch,
|
||||
mdiTransmissionTower,
|
||||
mdiWater,
|
||||
mdiWaterPercent,
|
||||
mdiWeatherPartlyCloudy,
|
||||
mdiWeatherPouring,
|
||||
mdiWeatherRainy,
|
||||
mdiWeatherWindy,
|
||||
mdiWeight,
|
||||
mdiWhiteBalanceSunny,
|
||||
mdiWifi,
|
||||
} from "@mdi/js";
|
||||
|
||||
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
|
||||
|
||||
// Constants should be alphabetically sorted by name.
|
||||
// Arrays with values should be alphabetically sorted if order doesn't matter.
|
||||
// Each constant should have a description what it is supposed to be used for.
|
||||
|
||||
/** Icon to use when no icon specified for service. */
|
||||
export const DEFAULT_SERVICE_ICON = mdiRoomService;
|
||||
|
||||
/** Icon to use when no icon specified for domain. */
|
||||
export const DEFAULT_DOMAIN_ICON = mdiBookmark;
|
||||
|
||||
/** Icons for each domain */
|
||||
export const FIXED_DOMAIN_ICONS = {
|
||||
air_quality: mdiAirFilter,
|
||||
alert: mdiAlert,
|
||||
automation: mdiRobot,
|
||||
calendar: mdiCalendar,
|
||||
climate: mdiThermostat,
|
||||
configurator: mdiCog,
|
||||
conversation: mdiForumOutline,
|
||||
counter: mdiCounter,
|
||||
date: mdiCalendar,
|
||||
datetime: mdiCalendarClock,
|
||||
demo: mdiHomeAssistant,
|
||||
device_tracker: mdiAccount,
|
||||
google_assistant: mdiGoogleAssistant,
|
||||
group: mdiGoogleCirclesCommunities,
|
||||
homeassistant: mdiHomeAssistant,
|
||||
homekit: mdiHomeAutomation,
|
||||
image_processing: mdiImageFilterFrames,
|
||||
image: mdiImage,
|
||||
input_boolean: mdiToggleSwitch,
|
||||
input_button: mdiButtonPointer,
|
||||
input_datetime: mdiCalendarClock,
|
||||
input_number: mdiRayVertex,
|
||||
input_select: mdiFormatListBulleted,
|
||||
input_text: mdiFormTextbox,
|
||||
lawn_mower: mdiRobotMower,
|
||||
light: mdiLightbulb,
|
||||
notify: mdiCommentAlert,
|
||||
number: mdiRayVertex,
|
||||
persistent_notification: mdiBell,
|
||||
person: mdiAccount,
|
||||
plant: mdiFlower,
|
||||
proximity: mdiAppleSafari,
|
||||
remote: mdiRemote,
|
||||
scene: mdiPalette,
|
||||
schedule: mdiCalendarClock,
|
||||
script: mdiScriptText,
|
||||
select: mdiFormatListBulleted,
|
||||
sensor: mdiEye,
|
||||
simple_alarm: mdiBell,
|
||||
siren: mdiBullhorn,
|
||||
stt: mdiMicrophoneMessage,
|
||||
sun: mdiWhiteBalanceSunny,
|
||||
text: mdiFormTextbox,
|
||||
time: mdiClock,
|
||||
timer: mdiTimerOutline,
|
||||
todo: mdiClipboardList,
|
||||
tts: mdiSpeakerMessage,
|
||||
vacuum: mdiRobotVacuum,
|
||||
wake_word: mdiChatSleep,
|
||||
weather: mdiWeatherPartlyCloudy,
|
||||
zone: mdiMapMarkerRadius,
|
||||
};
|
||||
|
||||
export const FIXED_DEVICE_CLASS_ICONS = {
|
||||
apparent_power: mdiFlash,
|
||||
aqi: mdiAirFilter,
|
||||
atmospheric_pressure: mdiThermometerLines,
|
||||
// battery: mdiBattery, => not included by design since `sensorIcon()` will dynamically determine the icon
|
||||
carbon_dioxide: mdiMoleculeCo2,
|
||||
carbon_monoxide: mdiMoleculeCo,
|
||||
current: mdiCurrentAc,
|
||||
data_rate: mdiTransmissionTower,
|
||||
data_size: mdiDatabase,
|
||||
date: mdiCalendar,
|
||||
distance: mdiArrowLeftRight,
|
||||
duration: mdiProgressClock,
|
||||
energy: mdiLightningBolt,
|
||||
frequency: mdiSineWave,
|
||||
gas: mdiMeterGas,
|
||||
humidity: mdiWaterPercent,
|
||||
illuminance: mdiBrightness5,
|
||||
irradiance: mdiSunWireless,
|
||||
moisture: mdiWaterPercent,
|
||||
monetary: mdiCash,
|
||||
nitrogen_dioxide: mdiMolecule,
|
||||
nitrogen_monoxide: mdiMolecule,
|
||||
nitrous_oxide: mdiMolecule,
|
||||
ozone: mdiMolecule,
|
||||
ph: mdiPh,
|
||||
pm1: mdiMolecule,
|
||||
pm10: mdiMolecule,
|
||||
pm25: mdiMolecule,
|
||||
power: mdiFlash,
|
||||
power_factor: mdiAngleAcute,
|
||||
precipitation: mdiWeatherRainy,
|
||||
precipitation_intensity: mdiWeatherPouring,
|
||||
pressure: mdiGauge,
|
||||
reactive_power: mdiFlash,
|
||||
shopping_List: mdiFormatListCheckbox,
|
||||
signal_strength: mdiWifi,
|
||||
sound_pressure: mdiEarHearing,
|
||||
speed: mdiSpeedometer,
|
||||
sulphur_dioxide: mdiMolecule,
|
||||
temperature: mdiThermometer,
|
||||
timestamp: mdiClock,
|
||||
volatile_organic_compounds: mdiMolecule,
|
||||
volatile_organic_compounds_parts: mdiMolecule,
|
||||
voltage: mdiSineWave,
|
||||
volume: mdiCarCoolantLevel,
|
||||
volume_flow_rate: mdiPipe,
|
||||
water: mdiWater,
|
||||
weight: mdiWeight,
|
||||
wind_speed: mdiWeatherWindy,
|
||||
};
|
||||
|
||||
/** Domains that have a state card. */
|
||||
export const DOMAINS_WITH_CARD = [
|
||||
"alert",
|
||||
|
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
addMilliseconds,
|
||||
addMonths,
|
||||
isFirstDayOfMonth,
|
||||
isLastDayOfMonth,
|
||||
differenceInMilliseconds,
|
||||
differenceInMonths,
|
||||
endOfMonth,
|
||||
} from "date-fns";
|
||||
import { toZonedTime, fromZonedTime } from "date-fns-tz";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
@@ -55,3 +64,55 @@ export const calcDateDifferenceProperty = (
|
||||
? toZonedTime(startDate, config.time_zone)
|
||||
: startDate
|
||||
);
|
||||
|
||||
export const shiftDateRange = (
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
forward: boolean,
|
||||
locale: FrontendLocaleData,
|
||||
config: any
|
||||
): { start: Date; end: Date } => {
|
||||
let start: Date;
|
||||
let end: Date;
|
||||
if (
|
||||
(calcDateProperty(
|
||||
startDate,
|
||||
isFirstDayOfMonth,
|
||||
locale,
|
||||
config
|
||||
) as boolean) &&
|
||||
(calcDateProperty(endDate, isLastDayOfMonth, locale, config) as boolean)
|
||||
) {
|
||||
const difference =
|
||||
((calcDateDifferenceProperty(
|
||||
endDate,
|
||||
startDate,
|
||||
differenceInMonths,
|
||||
locale,
|
||||
config
|
||||
) as number) +
|
||||
1) *
|
||||
(forward ? 1 : -1);
|
||||
start = calcDate(startDate, addMonths, locale, config, difference);
|
||||
end = calcDate(
|
||||
calcDate(endDate, addMonths, locale, config, difference),
|
||||
endOfMonth,
|
||||
locale,
|
||||
config
|
||||
);
|
||||
} else {
|
||||
const difference =
|
||||
((calcDateDifferenceProperty(
|
||||
endDate,
|
||||
startDate,
|
||||
differenceInMilliseconds,
|
||||
locale,
|
||||
config
|
||||
) as number) +
|
||||
1) *
|
||||
(forward ? 1 : -1);
|
||||
start = calcDate(startDate, addMilliseconds, locale, config, difference);
|
||||
end = calcDate(endDate, addMilliseconds, locale, config, difference);
|
||||
}
|
||||
return { start, end };
|
||||
};
|
||||
|
@@ -15,6 +15,7 @@ export const FIXED_DOMAIN_STATES = {
|
||||
"pending",
|
||||
"triggered",
|
||||
],
|
||||
assist_satellite: ["idle", "listening", "responding", "processing"],
|
||||
automation: ["on", "off"],
|
||||
binary_sensor: ["on", "off"],
|
||||
button: [],
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { closeAllDialogs } from "../dialogs/make-dialog-manager";
|
||||
import { fireEvent } from "./dom/fire_event";
|
||||
import { mainWindow } from "./dom/get_main_window";
|
||||
|
||||
@@ -13,15 +14,35 @@ export interface NavigateOptions {
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export const navigate = (path: string, options?: NavigateOptions) => {
|
||||
// max time to wait for dialogs to close before navigating
|
||||
const DIALOG_WAIT_TIMEOUT = 500;
|
||||
|
||||
export const navigate = async (
|
||||
path: string,
|
||||
options?: NavigateOptions,
|
||||
timestamp = Date.now()
|
||||
) => {
|
||||
const { history } = mainWindow;
|
||||
if (history.state?.dialog && Date.now() - timestamp < DIALOG_WAIT_TIMEOUT) {
|
||||
const closed = await closeAllDialogs();
|
||||
if (!closed) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Navigation blocked, because dialog refused to close");
|
||||
return false;
|
||||
}
|
||||
return new Promise<boolean>((resolve) => {
|
||||
// need to wait for history state to be updated in case a dialog was closed
|
||||
setTimeout(() => {
|
||||
navigate(path, options, timestamp).then(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__) {
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
history.replaceState(
|
||||
history.state?.root ? { root: true } : (options?.data ?? null),
|
||||
"",
|
||||
`${mainWindow.location.pathname}#${path}`
|
||||
);
|
||||
@@ -29,15 +50,16 @@ export const navigate = (path: string, options?: NavigateOptions) => {
|
||||
mainWindow.location.hash = path;
|
||||
}
|
||||
} else if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root ? { root: true } : (options?.data ?? null),
|
||||
history.replaceState(
|
||||
history.state?.root ? { root: true } : (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
@@ -1,4 +1,4 @@
|
||||
export const copyToClipboard = async (str) => {
|
||||
export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
|
||||
if (navigator.clipboard) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(str);
|
||||
@@ -8,10 +8,12 @@ export const copyToClipboard = async (str) => {
|
||||
}
|
||||
}
|
||||
|
||||
const root = rootEl ?? document.body;
|
||||
|
||||
const el = document.createElement("textarea");
|
||||
el.value = str;
|
||||
document.body.appendChild(el);
|
||||
root.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(el);
|
||||
root.removeChild(el);
|
||||
};
|
||||
|
@@ -3,7 +3,7 @@
|
||||
// Returns a function, that, as long as it continues to be invoked, will not
|
||||
// be triggered. The function will be called after it stops being called for
|
||||
// N milliseconds. If `immediate` is passed, trigger the function on the
|
||||
// leading edge, instead of the trailing.
|
||||
// leading edge and on the trailing.
|
||||
|
||||
export const debounce = <T extends any[]>(
|
||||
func: (...args: T) => void,
|
||||
@@ -14,9 +14,7 @@ export const debounce = <T extends any[]>(
|
||||
const debouncedFunc = (...args: T): void => {
|
||||
const later = () => {
|
||||
timeout = undefined;
|
||||
if (!immediate) {
|
||||
func(...args);
|
||||
}
|
||||
func(...args);
|
||||
};
|
||||
const callNow = immediate && !timeout;
|
||||
clearTimeout(timeout);
|
||||
|
@@ -1,7 +1,25 @@
|
||||
class TimeoutError extends Error {
|
||||
public timeout: number;
|
||||
|
||||
constructor(timeout: number, ...params) {
|
||||
super(...params);
|
||||
|
||||
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, TimeoutError);
|
||||
}
|
||||
|
||||
this.name = "TimeoutError";
|
||||
// Custom debugging information
|
||||
this.timeout = timeout;
|
||||
this.message = `Timed out in ${timeout} ms.`;
|
||||
}
|
||||
}
|
||||
|
||||
export const promiseTimeout = (ms: number, promise: Promise<any> | any) => {
|
||||
const timeout = new Promise((_resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(`Timed out in ${ms} ms.`);
|
||||
reject(new TimeoutError(ms));
|
||||
}, ms);
|
||||
});
|
||||
|
||||
|
@@ -10,10 +10,13 @@ import { css, html, nothing, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { mdiRestart } from "@mdi/js";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { clamp } from "../../common/number/clamp";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
import "../ha-icon-button";
|
||||
|
||||
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
|
||||
|
||||
@@ -60,10 +63,16 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
@state() private _chartHeight?: number;
|
||||
|
||||
@state() private _legendHeight?: number;
|
||||
|
||||
@state() private _tooltip?: Tooltip;
|
||||
|
||||
@state() private _hiddenDatasets: Set<number> = new Set();
|
||||
|
||||
@state() private _showZoomHint = false;
|
||||
|
||||
@state() private _isZoomed = false;
|
||||
|
||||
private _paddingUpdateCount = 0;
|
||||
|
||||
private _paddingUpdateLock = false;
|
||||
@@ -201,16 +210,30 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
this.chart.data = this.data;
|
||||
}
|
||||
if (changedProps.has("options")) {
|
||||
if (changedProps.has("options") && !this.chart.isZoomedOrPanned()) {
|
||||
// this resets the chart zoom because min/max scales changed
|
||||
// so we only do it if the user is not zooming or panning
|
||||
this.chart.options = this._createOptions();
|
||||
}
|
||||
this.chart.update("none");
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has("data") || changedProperties.has("options")) {
|
||||
if (this.options?.plugins?.legend?.display) {
|
||||
this._legendHeight =
|
||||
this.renderRoot.querySelector(".chart-legend")?.clientHeight;
|
||||
} else {
|
||||
this._legendHeight = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
${this.options?.plugins?.legend?.display === true
|
||||
? html`<div class="chartLegend">
|
||||
? html`<div class="chart-legend">
|
||||
<ul>
|
||||
${this._datasetOrder.map((index) => {
|
||||
const dataset = this.data.datasets[index];
|
||||
@@ -242,14 +265,14 @@ export class HaChartBase extends LitElement {
|
||||
</div>`
|
||||
: ""}
|
||||
<div
|
||||
class="animationContainer"
|
||||
class="animation-container"
|
||||
style=${styleMap({
|
||||
height: `${this.height || this._chartHeight || 0}px`,
|
||||
overflow: this._chartHeight ? "initial" : "hidden",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class="chartContainer"
|
||||
class="chart-container"
|
||||
style=${styleMap({
|
||||
height: `${
|
||||
this.height ?? this._chartHeight ?? this.clientWidth / 2
|
||||
@@ -259,11 +282,39 @@ export class HaChartBase extends LitElement {
|
||||
"padding-inline-start": `${this._paddingYAxisInternal}px`,
|
||||
"padding-inline-end": 0,
|
||||
})}
|
||||
@wheel=${this._handleChartScroll}
|
||||
>
|
||||
<canvas></canvas>
|
||||
<canvas
|
||||
class=${classMap({
|
||||
"not-zoomed": !this._isZoomed,
|
||||
})}
|
||||
></canvas>
|
||||
<div
|
||||
class="zoom-hint ${classMap({
|
||||
visible: this._showZoomHint,
|
||||
})}"
|
||||
>
|
||||
<div>
|
||||
${isMac
|
||||
? this.hass.localize(
|
||||
"ui.components.history_charts.zoom_hint_mac"
|
||||
)
|
||||
: this.hass.localize("ui.components.history_charts.zoom_hint")}
|
||||
</div>
|
||||
</div>
|
||||
${this._isZoomed && this.chartType !== "timeline"
|
||||
? html`<ha-icon-button
|
||||
class="zoom-reset"
|
||||
.path=${mdiRestart}
|
||||
@click=${this._handleZoomReset}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.history_charts.zoom_reset"
|
||||
)}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
${this._tooltip
|
||||
? html`<div
|
||||
class="chartTooltip ${classMap({
|
||||
class="chart-tooltip ${classMap({
|
||||
[this._tooltip.yAlign]: true,
|
||||
})}"
|
||||
style=${styleMap({
|
||||
@@ -273,7 +324,7 @@ export class HaChartBase extends LitElement {
|
||||
>
|
||||
<div class="title">${this._tooltip.title}</div>
|
||||
${this._tooltip.beforeBody
|
||||
? html`<div class="beforeBody">
|
||||
? html`<div class="before-body">
|
||||
${this._tooltip.beforeBody}
|
||||
</div>`
|
||||
: ""}
|
||||
@@ -343,9 +394,13 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _createOptions() {
|
||||
private _createOptions(): ChartOptions {
|
||||
const modifierKey = isMac ? "meta" : "ctrl";
|
||||
return {
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 500,
|
||||
},
|
||||
...this.options,
|
||||
plugins: {
|
||||
...this.options?.plugins,
|
||||
@@ -358,6 +413,53 @@ export class HaChartBase extends LitElement {
|
||||
...this.options?.plugins?.legend,
|
||||
display: false,
|
||||
},
|
||||
zoom: {
|
||||
...this.options?.plugins?.zoom,
|
||||
pan: {
|
||||
enabled: true,
|
||||
},
|
||||
zoom: {
|
||||
pinch: {
|
||||
enabled: true,
|
||||
},
|
||||
drag: {
|
||||
enabled: true,
|
||||
modifierKey,
|
||||
threshold: 2,
|
||||
},
|
||||
wheel: {
|
||||
enabled: true,
|
||||
modifierKey,
|
||||
speed: 0.05,
|
||||
},
|
||||
mode:
|
||||
this.chartType !== "timeline" &&
|
||||
(this.options?.scales?.y as any)?.type === "category"
|
||||
? "y"
|
||||
: "x",
|
||||
onZoomComplete: () => {
|
||||
const isZoomed = this.chart?.isZoomedOrPanned() ?? false;
|
||||
if (this._isZoomed && !isZoomed) {
|
||||
setTimeout(() => {
|
||||
// make sure the scales are properly reset after full zoom out
|
||||
// they get bugged when zooming in/out multiple times and panning
|
||||
this.chart?.resetZoom();
|
||||
});
|
||||
}
|
||||
this._isZoomed = isZoomed;
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
x: {
|
||||
min: "original",
|
||||
max: (this.options?.scales?.x as any)?.max ?? "original",
|
||||
},
|
||||
y: {
|
||||
min: "original",
|
||||
max: "original",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -382,6 +484,17 @@ export class HaChartBase extends LitElement {
|
||||
];
|
||||
}
|
||||
|
||||
private _handleChartScroll(ev: MouseEvent) {
|
||||
const modifier = isMac ? "metaKey" : "ctrlKey";
|
||||
this._tooltip = undefined;
|
||||
if (!ev[modifier] && !this._showZoomHint) {
|
||||
this._showZoomHint = true;
|
||||
setTimeout(() => {
|
||||
this._showZoomHint = false;
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private _legendClick(ev) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
@@ -416,15 +529,20 @@ export class HaChartBase extends LitElement {
|
||||
this._tooltip = undefined;
|
||||
return;
|
||||
}
|
||||
const boundingBox = this.getBoundingClientRect();
|
||||
this._tooltip = {
|
||||
...context.tooltip,
|
||||
top: this.chart!.canvas.offsetTop + context.tooltip.caretY + 12 + "px",
|
||||
top:
|
||||
boundingBox.y +
|
||||
(this._legendHeight || 0) +
|
||||
context.tooltip.caretY +
|
||||
12 +
|
||||
"px",
|
||||
left:
|
||||
this.chart!.canvas.offsetLeft +
|
||||
clamp(
|
||||
context.tooltip.caretX,
|
||||
100,
|
||||
this.clientWidth - 100 - this._paddingYAxisInternal
|
||||
boundingBox.x + context.tooltip.caretX,
|
||||
boundingBox.x + 100,
|
||||
boundingBox.x + boundingBox.width - 100
|
||||
) -
|
||||
100 +
|
||||
"px",
|
||||
@@ -439,24 +557,35 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleZoomReset() {
|
||||
this.chart?.resetZoom();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
position: var(--chart-base-position, relative);
|
||||
position: relative;
|
||||
}
|
||||
.animationContainer {
|
||||
.animation-container {
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.chart-container {
|
||||
position: relative;
|
||||
}
|
||||
canvas {
|
||||
max-height: var(--chart-max-height, 400px);
|
||||
}
|
||||
.chartLegend {
|
||||
canvas.not-zoomed {
|
||||
/* allow scrolling if the chart is not zoomed */
|
||||
touch-action: pan-y !important;
|
||||
}
|
||||
.chart-legend {
|
||||
text-align: center;
|
||||
}
|
||||
.chartLegend li {
|
||||
.chart-legend li {
|
||||
cursor: pointer;
|
||||
display: inline-grid;
|
||||
grid-auto-flow: column;
|
||||
@@ -465,16 +594,16 @@ export class HaChartBase extends LitElement {
|
||||
align-items: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.chartLegend .hidden {
|
||||
.chart-legend .hidden {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.chartLegend .label {
|
||||
.chart-legend .label {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chartLegend .bullet,
|
||||
.chartTooltip .bullet {
|
||||
.chart-legend .bullet,
|
||||
.chart-tooltip .bullet {
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-radius: 50%;
|
||||
@@ -488,13 +617,13 @@ export class HaChartBase extends LitElement {
|
||||
margin-inline-start: initial;
|
||||
direction: var(--direction);
|
||||
}
|
||||
.chartTooltip .bullet {
|
||||
.chart-tooltip .bullet {
|
||||
align-self: baseline;
|
||||
}
|
||||
.chartTooltip {
|
||||
.chart-tooltip {
|
||||
padding: 8px;
|
||||
font-size: 90%;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
background: rgba(80, 80, 80, 0.9);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
@@ -507,17 +636,17 @@ export class HaChartBase extends LitElement {
|
||||
box-sizing: border-box;
|
||||
direction: var(--direction);
|
||||
}
|
||||
.chartLegend ul,
|
||||
.chartTooltip ul {
|
||||
.chart-legend ul,
|
||||
.chart-tooltip ul {
|
||||
display: inline-block;
|
||||
padding: 0 0px;
|
||||
margin: 8px 0 0 0;
|
||||
width: 100%;
|
||||
}
|
||||
.chartTooltip ul {
|
||||
.chart-tooltip ul {
|
||||
margin: 0 4px;
|
||||
}
|
||||
.chartTooltip li {
|
||||
.chart-tooltip li {
|
||||
display: flex;
|
||||
white-space: pre-line;
|
||||
word-break: break-word;
|
||||
@@ -525,20 +654,55 @@ export class HaChartBase extends LitElement {
|
||||
line-height: 16px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.chartTooltip .title {
|
||||
.chart-tooltip .title {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
direction: ltr;
|
||||
}
|
||||
.chartTooltip .footer {
|
||||
.chart-tooltip .footer {
|
||||
font-weight: 500;
|
||||
}
|
||||
.chartTooltip .beforeBody {
|
||||
.chart-tooltip .before-body {
|
||||
text-align: center;
|
||||
font-weight: 300;
|
||||
word-break: break-all;
|
||||
}
|
||||
.zoom-hint {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 500ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.zoom-hint.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.zoom-hint > div {
|
||||
color: white;
|
||||
font-size: 1.5em;
|
||||
font-weight: 500;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 0 32px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.zoom-reset {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 4px;
|
||||
background: var(--card-background-color);
|
||||
border-radius: 4px;
|
||||
--mdc-icon-button-size: 32px;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
544
src/components/chart/sankey-chart.ts
Normal file
544
src/components/chart/sankey-chart.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { LitElement, html, css, svg, nothing } from "lit";
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export type Node = {
|
||||
id: string;
|
||||
value: number;
|
||||
index: number; // like z-index but for x/y
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
color?: string;
|
||||
passThrough?: boolean;
|
||||
};
|
||||
export type Link = { source: string; target: string; value?: number };
|
||||
|
||||
export type SankeyChartData = {
|
||||
nodes: Node[];
|
||||
links: Link[];
|
||||
};
|
||||
|
||||
type ProcessedNode = Node & {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type ProcessedLink = Link & {
|
||||
value: number;
|
||||
offset: {
|
||||
source: number;
|
||||
target: number;
|
||||
};
|
||||
passThroughNodeIds: string[];
|
||||
};
|
||||
|
||||
type Section = {
|
||||
nodes: ProcessedNode[];
|
||||
offset: number;
|
||||
index: number;
|
||||
totalValue: number;
|
||||
statePerPixel: number;
|
||||
};
|
||||
|
||||
const MIN_SIZE = 3;
|
||||
const DEFAULT_COLOR = "var(--primary-color)";
|
||||
const NODE_WIDTH = 15;
|
||||
const FONT_SIZE = 12;
|
||||
const MIN_DISTANCE = FONT_SIZE / 2;
|
||||
|
||||
@customElement("sankey-chart")
|
||||
export class SankeyChart extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public data: SankeyChartData = {
|
||||
nodes: [],
|
||||
links: [],
|
||||
};
|
||||
|
||||
@property({ type: Boolean }) public vertical = false;
|
||||
|
||||
@property({ attribute: false }) public loadingText?: string;
|
||||
|
||||
private _statePerPixel = 0;
|
||||
|
||||
private _textMeasureCanvas?: HTMLCanvasElement;
|
||||
|
||||
private _sizeController = new ResizeController(this, {
|
||||
callback: (entries) => entries[0]?.contentRect,
|
||||
});
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._textMeasureCanvas = undefined;
|
||||
}
|
||||
|
||||
willUpdate() {
|
||||
this._statePerPixel = 0;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._sizeController.value) {
|
||||
return this.loadingText ?? nothing;
|
||||
}
|
||||
|
||||
const { width, height } = this._sizeController.value;
|
||||
const { nodes, paths } = this._processNodesAndPaths(
|
||||
this.data.nodes,
|
||||
this.data.links
|
||||
);
|
||||
|
||||
return html`
|
||||
<svg
|
||||
width=${width}
|
||||
height=${height}
|
||||
viewBox="0 0 ${width} ${height}"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
${paths.map(
|
||||
(path, i) => svg`
|
||||
<linearGradient id="gradient${path.sourceNode.id}.${path.targetNode.id}.${i}" gradientTransform="${
|
||||
this.vertical ? "rotate(90)" : ""
|
||||
}">
|
||||
<stop offset="0%" stop-color="${path.sourceNode.color}"></stop>
|
||||
<stop offset="100%" stop-color="${path.targetNode.color}"></stop>
|
||||
</linearGradient>
|
||||
`
|
||||
)}
|
||||
</defs>
|
||||
${paths.map(
|
||||
(path, i) =>
|
||||
svg`
|
||||
<path d="${path.path.map(([cmd, x, y]) => `${cmd}${x},${y}`).join(" ")} Z"
|
||||
fill="url(#gradient${path.sourceNode.id}.${path.targetNode.id}.${i})" fill-opacity="0.4" />
|
||||
`
|
||||
)}
|
||||
${nodes.map((node) =>
|
||||
node.passThrough
|
||||
? nothing
|
||||
: svg`
|
||||
<g transform="translate(${node.x},${node.y})">
|
||||
<rect
|
||||
class="node"
|
||||
width=${this.vertical ? node.size : NODE_WIDTH}
|
||||
height=${this.vertical ? NODE_WIDTH : node.size}
|
||||
style="fill: ${node.color}"
|
||||
>
|
||||
<title>${node.tooltip}</title>
|
||||
</rect>
|
||||
${
|
||||
this.vertical
|
||||
? nothing
|
||||
: svg`
|
||||
<text
|
||||
class="node-label"
|
||||
x=${NODE_WIDTH + 5}
|
||||
y=${node.size / 2}
|
||||
text-anchor="start"
|
||||
dominant-baseline="middle"
|
||||
>${node.label}</text>
|
||||
`
|
||||
}
|
||||
</g>
|
||||
`
|
||||
)}
|
||||
</svg>
|
||||
${this.vertical
|
||||
? nodes.map((node) => {
|
||||
if (!node.label) {
|
||||
return nothing;
|
||||
}
|
||||
const labelWidth = MIN_DISTANCE + node.size;
|
||||
const fontSize = this._getVerticalLabelFontSize(
|
||||
node.label,
|
||||
labelWidth
|
||||
);
|
||||
return html`<div
|
||||
class="node-label vertical"
|
||||
style="
|
||||
left: ${node.x - MIN_DISTANCE / 2}px;
|
||||
top: ${node.y + NODE_WIDTH}px;
|
||||
width: ${labelWidth}px;
|
||||
height: ${FONT_SIZE * 3}px;
|
||||
font-size: ${fontSize}px;
|
||||
line-height: ${fontSize}px;
|
||||
"
|
||||
title=${node.label}
|
||||
>
|
||||
${node.label}
|
||||
</div>`;
|
||||
})
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _processNodesAndPaths = memoizeOne(
|
||||
(rawNodes: Node[], rawLinks: Link[]) => {
|
||||
const filteredNodes = rawNodes.filter((n) => n.value > 0);
|
||||
const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
|
||||
const { links, passThroughNodes } = this._processLinks(
|
||||
filteredNodes,
|
||||
indexes,
|
||||
rawLinks
|
||||
);
|
||||
const nodes = this._processNodes(
|
||||
[...filteredNodes, ...passThroughNodes],
|
||||
indexes
|
||||
);
|
||||
const paths = this._processPaths(nodes, links);
|
||||
return { nodes, paths };
|
||||
}
|
||||
);
|
||||
|
||||
private _processLinks(nodes: Node[], indexes: number[], rawLinks: Link[]) {
|
||||
const accountedIn = new Map<string, number>();
|
||||
const accountedOut = new Map<string, number>();
|
||||
const links: ProcessedLink[] = [];
|
||||
const passThroughNodes: Node[] = [];
|
||||
rawLinks.forEach((link) => {
|
||||
const sourceNode = nodes.find((n) => n.id === link.source);
|
||||
const targetNode = nodes.find((n) => n.id === link.target);
|
||||
if (!sourceNode || !targetNode) {
|
||||
return;
|
||||
}
|
||||
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
|
||||
const targetAccounted = accountedIn.get(targetNode.id) || 0;
|
||||
|
||||
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
|
||||
const sourceRemaining = sourceNode.value - sourceAccounted;
|
||||
const targetRemaining = targetNode.value - targetAccounted;
|
||||
// ensure the value is not greater than the remaining capacity of the nodes
|
||||
const value = Math.min(
|
||||
link.value ?? sourceRemaining,
|
||||
sourceRemaining,
|
||||
targetRemaining
|
||||
);
|
||||
|
||||
accountedIn.set(targetNode.id, targetAccounted + value);
|
||||
accountedOut.set(sourceNode.id, sourceAccounted + value);
|
||||
|
||||
// handle links across sections
|
||||
const sourceIndex = indexes.findIndex((i) => i === sourceNode.index);
|
||||
const targetIndex = indexes.findIndex((i) => i === targetNode.index);
|
||||
const passThroughSections = indexes.slice(sourceIndex + 1, targetIndex);
|
||||
// create pass-through nodes to reserve space
|
||||
const passThroughNodeIds = passThroughSections.map((index) => {
|
||||
const node = {
|
||||
passThrough: true,
|
||||
id: `${sourceNode.id}-${targetNode.id}-${index}`,
|
||||
value,
|
||||
index,
|
||||
};
|
||||
passThroughNodes.push(node);
|
||||
return node.id;
|
||||
});
|
||||
|
||||
if (value > 0) {
|
||||
links.push({
|
||||
...link,
|
||||
value,
|
||||
offset: {
|
||||
source: sourceAccounted / (sourceNode.value || 1),
|
||||
target: targetAccounted / (targetNode.value || 1),
|
||||
},
|
||||
passThroughNodeIds,
|
||||
});
|
||||
}
|
||||
});
|
||||
return { links, passThroughNodes };
|
||||
}
|
||||
|
||||
private _processNodes(filteredNodes: Node[], indexes: number[]) {
|
||||
// add MIN_DISTANCE as padding
|
||||
const sectionSize = this.vertical
|
||||
? this._sizeController.value!.width - MIN_DISTANCE * 2
|
||||
: this._sizeController.value!.height - MIN_DISTANCE * 2;
|
||||
|
||||
const nodesPerSection: Record<number, Node[]> = {};
|
||||
filteredNodes.forEach((node) => {
|
||||
if (!nodesPerSection[node.index]) {
|
||||
nodesPerSection[node.index] = [node];
|
||||
} else {
|
||||
nodesPerSection[node.index].push(node);
|
||||
}
|
||||
});
|
||||
|
||||
const sectionFlexSize = this._getSectionFlexSize(
|
||||
Object.values(nodesPerSection)
|
||||
);
|
||||
|
||||
const sections: Section[] = indexes.map((index, i) => {
|
||||
const nodes: ProcessedNode[] = nodesPerSection[index].map(
|
||||
(node: Node) => ({
|
||||
...node,
|
||||
color: node.color || DEFAULT_COLOR,
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 0,
|
||||
})
|
||||
);
|
||||
const availableSpace =
|
||||
sectionSize - (nodes.length * MIN_DISTANCE - MIN_DISTANCE);
|
||||
const totalValue = nodes.reduce(
|
||||
(acc: number, node: Node) => acc + node.value,
|
||||
0
|
||||
);
|
||||
const { nodes: sizedNodes, statePerPixel } = this._setNodeSizes(
|
||||
nodes,
|
||||
availableSpace,
|
||||
totalValue
|
||||
);
|
||||
return {
|
||||
nodes: sizedNodes,
|
||||
offset: sectionFlexSize * i,
|
||||
index,
|
||||
totalValue,
|
||||
statePerPixel,
|
||||
};
|
||||
});
|
||||
|
||||
sections.forEach((section) => {
|
||||
// calc sizes again with the best statePerPixel
|
||||
let totalSize = 0;
|
||||
if (section.statePerPixel !== this._statePerPixel) {
|
||||
section.nodes.forEach((node) => {
|
||||
const size = Math.max(
|
||||
MIN_SIZE,
|
||||
Math.floor(node.value / this._statePerPixel)
|
||||
);
|
||||
totalSize += size;
|
||||
node.size = size;
|
||||
});
|
||||
} else {
|
||||
totalSize = section.nodes.reduce((sum, b) => sum + b.size, 0);
|
||||
}
|
||||
// calc margin betwee boxes
|
||||
const emptySpace = sectionSize - totalSize;
|
||||
const spacerSize = emptySpace / (section.nodes.length - 1);
|
||||
|
||||
// account for MIN_DISTANCE padding and center single node sections
|
||||
let offset =
|
||||
section.nodes.length > 1 ? MIN_DISTANCE : emptySpace / 2 + MIN_DISTANCE;
|
||||
// calc positions - swap x/y for vertical layout
|
||||
section.nodes.forEach((node) => {
|
||||
if (this.vertical) {
|
||||
node.x = offset;
|
||||
node.y = section.offset;
|
||||
} else {
|
||||
node.x = section.offset;
|
||||
node.y = offset;
|
||||
}
|
||||
offset += node.size + spacerSize;
|
||||
});
|
||||
});
|
||||
|
||||
return sections.flatMap((section) => section.nodes);
|
||||
}
|
||||
|
||||
private _processPaths(nodes: ProcessedNode[], links: ProcessedLink[]) {
|
||||
const flowDirection = this.vertical ? "y" : "x";
|
||||
const orthDirection = this.vertical ? "x" : "y"; // orthogonal to the flow
|
||||
const nodesById = new Map(nodes.map((n) => [n.id, n]));
|
||||
return links.map((link) => {
|
||||
const { source, target, value, offset, passThroughNodeIds } = link;
|
||||
const pathNodes = [source, ...passThroughNodeIds, target].map(
|
||||
(id) => nodesById.get(id)!
|
||||
);
|
||||
const offsets = [
|
||||
offset.source,
|
||||
...link.passThroughNodeIds.map(() => 0),
|
||||
offset.target,
|
||||
];
|
||||
|
||||
const sourceNode = pathNodes[0];
|
||||
const targetNode = pathNodes[pathNodes.length - 1];
|
||||
|
||||
let path: [string, number, number][] = [
|
||||
[
|
||||
"M",
|
||||
sourceNode[flowDirection] + NODE_WIDTH,
|
||||
sourceNode[orthDirection] + offset.source * sourceNode.size,
|
||||
],
|
||||
]; // starting point
|
||||
|
||||
// traverse the path forwards. stop before the last node
|
||||
for (let i = 0; i < pathNodes.length - 1; i++) {
|
||||
const node = pathNodes[i];
|
||||
const nextNode = pathNodes[i + 1];
|
||||
const flowMiddle =
|
||||
(nextNode[flowDirection] - node[flowDirection]) / 2 +
|
||||
node[flowDirection];
|
||||
const orthStart = node[orthDirection] + offsets[i] * node.size;
|
||||
const orthEnd =
|
||||
nextNode[orthDirection] + offsets[i + 1] * nextNode.size;
|
||||
path.push(
|
||||
["L", node[flowDirection] + NODE_WIDTH, orthStart],
|
||||
["C", flowMiddle, orthStart],
|
||||
["", flowMiddle, orthEnd],
|
||||
["", nextNode[flowDirection], orthEnd]
|
||||
);
|
||||
}
|
||||
// traverse the path backwards. stop before the first node
|
||||
for (let i = pathNodes.length - 1; i > 0; i--) {
|
||||
const node = pathNodes[i];
|
||||
const prevNode = pathNodes[i - 1];
|
||||
const flowMiddle =
|
||||
(node[flowDirection] - prevNode[flowDirection]) / 2 +
|
||||
prevNode[flowDirection];
|
||||
const orthStart =
|
||||
node[orthDirection] +
|
||||
offsets[i] * node.size +
|
||||
Math.max((value / (node.value || 1)) * node.size, 0);
|
||||
const orthEnd =
|
||||
prevNode[orthDirection] +
|
||||
offsets[i - 1] * prevNode.size +
|
||||
Math.max((value / (prevNode.value || 1)) * prevNode.size, 0);
|
||||
path.push(
|
||||
["L", node[flowDirection], orthStart],
|
||||
["C", flowMiddle, orthStart],
|
||||
["", flowMiddle, orthEnd],
|
||||
["", prevNode[flowDirection] + NODE_WIDTH, orthEnd]
|
||||
);
|
||||
}
|
||||
|
||||
if (this.vertical) {
|
||||
// Just swap x and y coordinates for vertical layout
|
||||
path = path.map((c) => [c[0], c[2], c[1]]);
|
||||
}
|
||||
return {
|
||||
sourceNode,
|
||||
targetNode,
|
||||
value,
|
||||
path,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _setNodeSizes(
|
||||
nodes: ProcessedNode[],
|
||||
availableSpace: number,
|
||||
totalValue: number
|
||||
): { nodes: ProcessedNode[]; statePerPixel: number } {
|
||||
const statePerPixel = totalValue / availableSpace;
|
||||
if (statePerPixel > this._statePerPixel) {
|
||||
this._statePerPixel = statePerPixel;
|
||||
}
|
||||
let deficitHeight = 0;
|
||||
const result = nodes.map((node) => {
|
||||
if (node.size === MIN_SIZE) {
|
||||
return node;
|
||||
}
|
||||
let size = Math.floor(node.value / this._statePerPixel);
|
||||
if (size < MIN_SIZE) {
|
||||
deficitHeight += MIN_SIZE - size;
|
||||
size = MIN_SIZE;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
size,
|
||||
};
|
||||
});
|
||||
if (deficitHeight > 0) {
|
||||
return this._setNodeSizes(
|
||||
result,
|
||||
availableSpace - deficitHeight,
|
||||
totalValue
|
||||
);
|
||||
}
|
||||
return { nodes: result, statePerPixel: this._statePerPixel };
|
||||
}
|
||||
|
||||
private _getSectionFlexSize(nodesPerSection: Node[][]): number {
|
||||
const fullSize = this.vertical
|
||||
? this._sizeController.value!.height
|
||||
: this._sizeController.value!.width;
|
||||
if (nodesPerSection.length < 2) {
|
||||
return fullSize;
|
||||
}
|
||||
let lastSectionFlexSize: number;
|
||||
if (this.vertical) {
|
||||
lastSectionFlexSize = FONT_SIZE * 2 + NODE_WIDTH; // estimated based on the font size + some margin
|
||||
} else {
|
||||
// Estimate the width needed for the last section based on label length
|
||||
const lastIndex = nodesPerSection.length - 1;
|
||||
const lastSectionNodes = nodesPerSection[lastIndex];
|
||||
const TEXT_PADDING = 5; // Padding between node and text
|
||||
lastSectionFlexSize =
|
||||
lastSectionNodes.length > 0
|
||||
? Math.max(
|
||||
...lastSectionNodes.map(
|
||||
(node) =>
|
||||
NODE_WIDTH +
|
||||
TEXT_PADDING +
|
||||
(node.label ? this._getTextWidth(node.label) : 0)
|
||||
)
|
||||
)
|
||||
: 0;
|
||||
}
|
||||
// Calculate the flex size for other sections
|
||||
const remainingSize = fullSize - lastSectionFlexSize;
|
||||
const flexSize = remainingSize / (nodesPerSection.length - 1);
|
||||
// if the last section is bigger than the others, we make them all the same size
|
||||
// this is to prevent the last section from squishing the others
|
||||
return lastSectionFlexSize < flexSize
|
||||
? flexSize
|
||||
: fullSize / nodesPerSection.length;
|
||||
}
|
||||
|
||||
private _getTextWidth(text: string): number {
|
||||
if (!this._textMeasureCanvas) {
|
||||
this._textMeasureCanvas = document.createElement("canvas");
|
||||
}
|
||||
const context = this._textMeasureCanvas.getContext("2d");
|
||||
if (!context) return 0;
|
||||
|
||||
// Match the font style from CSS
|
||||
context.font = `${FONT_SIZE}px sans-serif`;
|
||||
return context.measureText(text).width;
|
||||
}
|
||||
|
||||
private _getVerticalLabelFontSize(label: string, labelWidth: number): number {
|
||||
// reduce the label font size so the longest word fits on one line
|
||||
const longestWord = label
|
||||
.split(" ")
|
||||
.reduce(
|
||||
(longest, current) =>
|
||||
longest.length > current.length ? longest : current,
|
||||
""
|
||||
);
|
||||
const wordWidth = this._getTextWidth(longestWord);
|
||||
return Math.min(FONT_SIZE, (labelWidth / wordWidth) * FONT_SIZE);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
flex: 1;
|
||||
background: var(--ha-card-background, var(--card-background-color, #000));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
svg {
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
}
|
||||
.node-label {
|
||||
font-size: ${FONT_SIZE}px;
|
||||
fill: var(--primary-text-color, white);
|
||||
}
|
||||
.node-label.vertical {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"sankey-chart": SankeyChart;
|
||||
}
|
||||
}
|
@@ -99,7 +99,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
) {
|
||||
this._chartOptions = {
|
||||
parsing: false,
|
||||
animation: false,
|
||||
interaction: {
|
||||
mode: "nearest",
|
||||
axis: "xy",
|
||||
@@ -114,7 +113,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
},
|
||||
},
|
||||
min: this.startTime,
|
||||
suggestedMax: this.endTime,
|
||||
max: this.endTime,
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
sampleSize: 5,
|
||||
|
@@ -103,10 +103,9 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
this._chartOptions = {
|
||||
maintainAspectRatio: false,
|
||||
parsing: false,
|
||||
animation: false,
|
||||
scales: {
|
||||
x: {
|
||||
type: "timeline",
|
||||
type: "time",
|
||||
position: "bottom",
|
||||
adapters: {
|
||||
date: {
|
||||
|
@@ -194,7 +194,6 @@ export class StatisticsChart extends LitElement {
|
||||
private _createOptions(unit?: string) {
|
||||
this._chartOptions = {
|
||||
parsing: false,
|
||||
animation: false,
|
||||
interaction: {
|
||||
mode: "nearest",
|
||||
axis: "x",
|
||||
|
@@ -17,7 +17,6 @@ declare module "chart.js" {
|
||||
datasetOptions: BarControllerDatasetOptions;
|
||||
defaultDataPoint: TimeLineData;
|
||||
parsedDataType: any;
|
||||
scales: "timeline";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -1,55 +0,0 @@
|
||||
import { TimeScale } from "chart.js";
|
||||
import type { TimeLineData } from "./const";
|
||||
|
||||
export class TimeLineScale extends TimeScale {
|
||||
static id = "timeline";
|
||||
|
||||
static defaults = {
|
||||
position: "bottom",
|
||||
tooltips: {
|
||||
mode: "nearest",
|
||||
},
|
||||
ticks: {
|
||||
autoSkip: true,
|
||||
},
|
||||
};
|
||||
|
||||
determineDataLimits() {
|
||||
const options = this.options;
|
||||
// @ts-ignore
|
||||
const adapter = this._adapter;
|
||||
const unit = options.time.unit || "day";
|
||||
let { min, max } = this.getUserBounds();
|
||||
|
||||
const chart = this.chart;
|
||||
|
||||
// Convert data to timestamps
|
||||
chart.data.datasets.forEach((dataset, index) => {
|
||||
if (!chart.isDatasetVisible(index)) {
|
||||
return;
|
||||
}
|
||||
for (const data of dataset.data as TimeLineData[]) {
|
||||
let timestamp0 = adapter.parse(data.start, this);
|
||||
let timestamp1 = adapter.parse(data.end, this);
|
||||
if (timestamp0 > timestamp1) {
|
||||
[timestamp0, timestamp1] = [timestamp1, timestamp0];
|
||||
}
|
||||
if (min > timestamp0 && timestamp0) {
|
||||
min = timestamp0;
|
||||
}
|
||||
if (max < timestamp1 && timestamp1) {
|
||||
max = timestamp1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// In case there is no valid min/max, var's use today limits
|
||||
min =
|
||||
isFinite(min) && !isNaN(min) ? min : +adapter.startOf(Date.now(), unit);
|
||||
max = isFinite(max) && !isNaN(max) ? max : +adapter.endOf(Date.now(), unit);
|
||||
|
||||
// Make sure that max is strictly higher than min (required by the lookup table)
|
||||
this.min = adapter.parse(options.min, this) ?? Math.min(min, max - 1);
|
||||
this.max = adapter.parse(options.max, this) ?? Math.max(min + 1, max);
|
||||
}
|
||||
}
|
@@ -515,7 +515,7 @@ export class HaDataTable extends LitElement {
|
||||
return html`<div class="mdc-data-table__row">${row.content}</div>`;
|
||||
}
|
||||
if (row.empty) {
|
||||
return html`<div class="mdc-data-table__row"></div>`;
|
||||
return html`<div class="mdc-data-table__row empty-row"></div>`;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
@@ -960,6 +960,13 @@ export class HaDataTable extends LitElement {
|
||||
width: var(--table-row-width, 100%);
|
||||
}
|
||||
|
||||
.mdc-data-table__row.empty-row {
|
||||
height: var(
|
||||
--data-table-empty-row-height,
|
||||
var(--data-table-row-height, 52px)
|
||||
);
|
||||
}
|
||||
|
||||
.mdc-data-table__row ~ .mdc-data-table__row {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
@@ -222,7 +222,9 @@ export class HaDevicePicker extends LitElement {
|
||||
|
||||
return {
|
||||
id: device.id,
|
||||
name: name,
|
||||
name:
|
||||
name ||
|
||||
this.hass.localize("ui.components.device-picker.unnamed_device"),
|
||||
area:
|
||||
device.area_id && areas[device.area_id]
|
||||
? areas[device.area_id].name
|
||||
|
@@ -113,71 +113,74 @@ export class StateBadge extends LitElement {
|
||||
|
||||
this.icon = true;
|
||||
|
||||
if (stateObj && this.overrideImage === undefined) {
|
||||
// hide icon if we have entity picture
|
||||
if (
|
||||
(stateObj.attributes.entity_picture_local ||
|
||||
stateObj.attributes.entity_picture) &&
|
||||
!this.overrideIcon
|
||||
) {
|
||||
let imageUrl =
|
||||
stateObj.attributes.entity_picture_local ||
|
||||
stateObj.attributes.entity_picture;
|
||||
if (stateObj) {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
if (this.overrideImage === undefined) {
|
||||
// hide icon if we have entity picture
|
||||
if (
|
||||
(stateObj.attributes.entity_picture_local ||
|
||||
stateObj.attributes.entity_picture) &&
|
||||
!this.overrideIcon
|
||||
) {
|
||||
let imageUrl =
|
||||
stateObj.attributes.entity_picture_local ||
|
||||
stateObj.attributes.entity_picture;
|
||||
if (this.hass) {
|
||||
imageUrl = this.hass.hassUrl(imageUrl);
|
||||
}
|
||||
if (domain === "camera") {
|
||||
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
|
||||
}
|
||||
backgroundImage = `url(${imageUrl})`;
|
||||
this.icon = false;
|
||||
} else if (this.color) {
|
||||
// Externally provided overriding color wins over state color
|
||||
iconStyle.color = this.color;
|
||||
} else if (this._stateColor) {
|
||||
const color = stateColorCss(stateObj);
|
||||
if (color) {
|
||||
iconStyle.color = color;
|
||||
}
|
||||
if (stateObj.attributes.rgb_color) {
|
||||
iconStyle.color = `rgb(${stateObj.attributes.rgb_color.join(",")})`;
|
||||
}
|
||||
if (stateObj.attributes.brightness) {
|
||||
const brightness = stateObj.attributes.brightness;
|
||||
if (typeof brightness !== "number") {
|
||||
const errorMessage = `Type error: state-badge expected number, but type of ${
|
||||
stateObj.entity_id
|
||||
}.attributes.brightness is ${typeof brightness} (${brightness})`;
|
||||
// eslint-disable-next-line
|
||||
console.warn(errorMessage);
|
||||
}
|
||||
iconStyle.filter = stateColorBrightness(stateObj);
|
||||
}
|
||||
if (stateObj.attributes.hvac_action) {
|
||||
const hvacAction = stateObj.attributes.hvac_action;
|
||||
if (hvacAction in CLIMATE_HVAC_ACTION_TO_MODE) {
|
||||
iconStyle.color = stateColorCss(
|
||||
stateObj,
|
||||
CLIMATE_HVAC_ACTION_TO_MODE[hvacAction]
|
||||
)!;
|
||||
} else {
|
||||
delete iconStyle.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.overrideImage) {
|
||||
let imageUrl = this.overrideImage;
|
||||
if (this.hass) {
|
||||
imageUrl = this.hass.hassUrl(imageUrl);
|
||||
}
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
if (domain === "camera") {
|
||||
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
|
||||
}
|
||||
backgroundImage = `url(${imageUrl})`;
|
||||
this.icon = false;
|
||||
if (domain === "update") {
|
||||
this.style.borderRadius = "0";
|
||||
} else if (domain === "media_player") {
|
||||
this.style.borderRadius = "8%";
|
||||
}
|
||||
} else if (this.color) {
|
||||
// Externally provided overriding color wins over state color
|
||||
iconStyle.color = this.color;
|
||||
} else if (this._stateColor) {
|
||||
const color = stateColorCss(stateObj);
|
||||
if (color) {
|
||||
iconStyle.color = color;
|
||||
}
|
||||
if (stateObj.attributes.rgb_color) {
|
||||
iconStyle.color = `rgb(${stateObj.attributes.rgb_color.join(",")})`;
|
||||
}
|
||||
if (stateObj.attributes.brightness) {
|
||||
const brightness = stateObj.attributes.brightness;
|
||||
if (typeof brightness !== "number") {
|
||||
const errorMessage = `Type error: state-badge expected number, but type of ${
|
||||
stateObj.entity_id
|
||||
}.attributes.brightness is ${typeof brightness} (${brightness})`;
|
||||
// eslint-disable-next-line
|
||||
console.warn(errorMessage);
|
||||
}
|
||||
iconStyle.filter = stateColorBrightness(stateObj);
|
||||
}
|
||||
if (stateObj.attributes.hvac_action) {
|
||||
const hvacAction = stateObj.attributes.hvac_action;
|
||||
if (hvacAction in CLIMATE_HVAC_ACTION_TO_MODE) {
|
||||
iconStyle.color = stateColorCss(
|
||||
stateObj,
|
||||
CLIMATE_HVAC_ACTION_TO_MODE[hvacAction]
|
||||
)!;
|
||||
} else {
|
||||
delete iconStyle.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.overrideImage) {
|
||||
let imageUrl = this.overrideImage;
|
||||
if (this.hass) {
|
||||
imageUrl = this.hass.hassUrl(imageUrl);
|
||||
|
||||
if (domain === "update") {
|
||||
this.style.borderRadius = "0";
|
||||
} else if (domain === "media_player" || domain === "camera") {
|
||||
this.style.borderRadius = "8%";
|
||||
}
|
||||
backgroundImage = `url(${imageUrl})`;
|
||||
this.icon = false;
|
||||
}
|
||||
|
||||
this._iconStyle = iconStyle;
|
||||
|
@@ -14,7 +14,8 @@ export class HaButtonMenu extends LitElement {
|
||||
|
||||
@property() public corner: Corner = "BOTTOM_START";
|
||||
|
||||
@property({ attribute: false }) public menuCorner: MenuCorner = "START";
|
||||
@property({ attribute: "menu-corner" }) public menuCorner: MenuCorner =
|
||||
"START";
|
||||
|
||||
@property({ type: Number }) public x: number | null = null;
|
||||
|
||||
|
@@ -8,7 +8,7 @@ export class HaCircularProgress extends MdCircularProgress {
|
||||
@property({ attribute: "aria-label", type: String }) public ariaLabel =
|
||||
"Loading";
|
||||
|
||||
@property() public size: "tiny" | "small" | "medium" | "large" = "medium";
|
||||
@property() public size?: "tiny" | "small" | "medium" | "large";
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
@@ -21,7 +21,6 @@ export class HaCircularProgress extends MdCircularProgress {
|
||||
case "small":
|
||||
this.style.setProperty("--md-circular-progress-size", "28px");
|
||||
break;
|
||||
// medium is default size
|
||||
case "medium":
|
||||
this.style.setProperty("--md-circular-progress-size", "48px");
|
||||
break;
|
||||
|
@@ -5,8 +5,6 @@ import "@material/mwc-list/mwc-list-item";
|
||||
import { mdiCalendar } from "@mdi/js";
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
addYears,
|
||||
endOfDay,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
@@ -15,25 +13,23 @@ import {
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
differenceInMilliseconds,
|
||||
addMilliseconds,
|
||||
subMilliseconds,
|
||||
roundToNearestHours,
|
||||
isThisYear,
|
||||
} from "date-fns";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { calcDate } from "../common/datetime/calc_date";
|
||||
import { calcDate, shiftDateRange } from "../common/datetime/calc_date";
|
||||
import { firstWeekdayIndex } from "../common/datetime/first_weekday";
|
||||
import { formatDate } from "../common/datetime/format_date";
|
||||
import { formatDateTime } from "../common/datetime/format_date_time";
|
||||
import {
|
||||
formatShortDateTimeWithYear,
|
||||
formatShortDateTime,
|
||||
} from "../common/datetime/format_date_time";
|
||||
import { useAmPm } from "../common/datetime/use_am_pm";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./date-range-picker";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-textfield";
|
||||
import "./ha-textarea";
|
||||
import "./ha-icon-button-next";
|
||||
import "./ha-icon-button-prev";
|
||||
|
||||
@@ -141,9 +137,6 @@ export class HaDateRangePicker extends LitElement {
|
||||
[this.hass.localize(
|
||||
"ui.components.date-range-picker.ranges.this_week"
|
||||
)]: [weekStart, weekEnd],
|
||||
[this.hass.localize(
|
||||
"ui.components.date-range-picker.ranges.last_week"
|
||||
)]: [addDays(weekStart, -7), addDays(weekEnd, -7)],
|
||||
...(this.extendedPresets
|
||||
? {
|
||||
[this.hass.localize(
|
||||
@@ -168,28 +161,6 @@ export class HaDateRangePicker extends LitElement {
|
||||
}
|
||||
),
|
||||
],
|
||||
[this.hass.localize(
|
||||
"ui.components.date-range-picker.ranges.last_month"
|
||||
)]: [
|
||||
calcDate(
|
||||
addMonths(today, -1),
|
||||
startOfMonth,
|
||||
this.hass.locale,
|
||||
this.hass.config,
|
||||
{
|
||||
weekStartsOn,
|
||||
}
|
||||
),
|
||||
calcDate(
|
||||
addMonths(today, -1),
|
||||
endOfMonth,
|
||||
this.hass.locale,
|
||||
this.hass.config,
|
||||
{
|
||||
weekStartsOn,
|
||||
}
|
||||
),
|
||||
],
|
||||
[this.hass.localize(
|
||||
"ui.components.date-range-picker.ranges.this_year"
|
||||
)]: [
|
||||
@@ -206,28 +177,6 @@ export class HaDateRangePicker extends LitElement {
|
||||
weekStartsOn,
|
||||
}),
|
||||
],
|
||||
[this.hass.localize(
|
||||
"ui.components.date-range-picker.ranges.last_year"
|
||||
)]: [
|
||||
calcDate(
|
||||
addYears(today, -1),
|
||||
startOfYear,
|
||||
this.hass.locale,
|
||||
this.hass.config,
|
||||
{
|
||||
weekStartsOn,
|
||||
}
|
||||
),
|
||||
calcDate(
|
||||
addYears(today, -1),
|
||||
endOfYear,
|
||||
this.hass.locale,
|
||||
this.hass.config,
|
||||
{
|
||||
weekStartsOn,
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
@@ -261,54 +210,49 @@ export class HaDateRangePicker extends LitElement {
|
||||
>
|
||||
<div slot="input" class="date-range-inputs" @click=${this._handleClick}>
|
||||
${!this.minimal
|
||||
? html`<ha-svg-icon .path=${mdiCalendar}></ha-svg-icon>
|
||||
<ha-icon-button-prev
|
||||
.label=${this.hass.localize("ui.common.previous")}
|
||||
class="prev"
|
||||
@click=${this._handlePrev}
|
||||
>
|
||||
</ha-icon-button-prev>
|
||||
<ha-textfield
|
||||
.value=${this.timePicker
|
||||
? formatDateTime(
|
||||
? html`<ha-textarea
|
||||
mobile-multiline
|
||||
.value=${(isThisYear(this.startDate)
|
||||
? formatShortDateTime(
|
||||
this.startDate,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)
|
||||
: formatDate(
|
||||
: formatShortDateTimeWithYear(
|
||||
this.startDate,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}
|
||||
)) +
|
||||
" - \n" +
|
||||
(isThisYear(this.endDate)
|
||||
? formatShortDateTime(
|
||||
this.endDate,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)
|
||||
: formatShortDateTimeWithYear(
|
||||
this.endDate,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
))}
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.date-range-picker.start_date"
|
||||
)}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._handleInputClick}
|
||||
readonly
|
||||
></ha-textfield>
|
||||
<ha-textfield
|
||||
.value=${this.timePicker
|
||||
? formatDateTime(
|
||||
this.endDate,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)
|
||||
: formatDate(
|
||||
this.endDate,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}
|
||||
.label=${this.hass.localize(
|
||||
) +
|
||||
" - " +
|
||||
this.hass.localize(
|
||||
"ui.components.date-range-picker.end_date"
|
||||
)}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._handleInputClick}
|
||||
readonly
|
||||
></ha-textfield>
|
||||
></ha-textarea>
|
||||
<ha-icon-button-prev
|
||||
.label=${this.hass.localize("ui.common.previous")}
|
||||
@click=${this._handlePrev}
|
||||
>
|
||||
</ha-icon-button-prev>
|
||||
<ha-icon-button-next
|
||||
.label=${this.hass.localize("ui.common.next")}
|
||||
class="next"
|
||||
@click=${this._handleNext}
|
||||
>
|
||||
</ha-icon-button-next>`
|
||||
@@ -342,40 +286,28 @@ export class HaDateRangePicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleNext(): void {
|
||||
const dateRange = [
|
||||
roundToNearestHours(this.endDate),
|
||||
subMilliseconds(
|
||||
roundToNearestHours(
|
||||
addMilliseconds(
|
||||
this.endDate,
|
||||
Math.max(
|
||||
3600000,
|
||||
differenceInMilliseconds(this.endDate, this.startDate)
|
||||
)
|
||||
)
|
||||
),
|
||||
1
|
||||
),
|
||||
];
|
||||
const dateRangePicker = this._dateRangePicker;
|
||||
dateRangePicker.clickRange(dateRange);
|
||||
dateRangePicker.clickedApply();
|
||||
private _handleNext(ev: MouseEvent): void {
|
||||
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||||
this._shift(true);
|
||||
}
|
||||
|
||||
private _handlePrev(): void {
|
||||
const dateRange = [
|
||||
roundToNearestHours(
|
||||
subMilliseconds(
|
||||
this.startDate,
|
||||
Math.max(
|
||||
3600000,
|
||||
differenceInMilliseconds(this.endDate, this.startDate)
|
||||
)
|
||||
)
|
||||
),
|
||||
subMilliseconds(roundToNearestHours(this.startDate), 1),
|
||||
];
|
||||
private _handlePrev(ev: MouseEvent): void {
|
||||
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||||
this._shift(false);
|
||||
}
|
||||
|
||||
private _shift(forward: boolean) {
|
||||
if (!this.startDate) return;
|
||||
const { start, end } = shiftDateRange(
|
||||
this.startDate,
|
||||
this.endDate,
|
||||
forward,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
this.startDate = start;
|
||||
this.endDate = end;
|
||||
const dateRange = [start, end];
|
||||
const dateRangePicker = this._dateRangePicker;
|
||||
dateRangePicker.clickRange(dateRange);
|
||||
dateRangePicker.clickedApply();
|
||||
@@ -430,12 +362,6 @@ export class HaDateRangePicker extends LitElement {
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return css`
|
||||
ha-svg-icon {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
margin-inline-start: initial;
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
ha-icon-button {
|
||||
direction: var(--direction);
|
||||
@@ -444,6 +370,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
.date-range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-range-ranges {
|
||||
@@ -457,17 +384,13 @@ export class HaDateRangePicker extends LitElement {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
ha-textfield {
|
||||
ha-textarea {
|
||||
display: inline-block;
|
||||
max-width: 250px;
|
||||
min-width: 220px;
|
||||
width: 340px;
|
||||
}
|
||||
|
||||
ha-textfield:last-child {
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
direction: var(--direction);
|
||||
@media only screen and (max-width: 460px) {
|
||||
ha-textarea {
|
||||
width: 100%
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 800px) {
|
||||
@@ -476,18 +399,6 @@ export class HaDateRangePicker extends LitElement {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
ha-textfield {
|
||||
min-width: inherit;
|
||||
}
|
||||
|
||||
ha-svg-icon,
|
||||
.prev,
|
||||
.next {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
@@ -15,13 +15,13 @@ export const createCloseHeading = (
|
||||
title: string | TemplateResult
|
||||
) => html`
|
||||
<div class="header_title">
|
||||
<span>${title}</span>
|
||||
<ha-icon-button
|
||||
.label=${hass?.localize("ui.dialogs.generic.close") ?? "Close"}
|
||||
.path=${mdiClose}
|
||||
dialogAction="close"
|
||||
class="header_button"
|
||||
></ha-icon-button>
|
||||
<span>${title}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -104,6 +104,9 @@ export class HaDialog extends DialogBase {
|
||||
.mdc-dialog__title {
|
||||
padding: 24px 24px 0 24px;
|
||||
}
|
||||
.mdc-dialog__title:has(span) {
|
||||
padding: 12px 12px 0;
|
||||
}
|
||||
.mdc-dialog__actions {
|
||||
padding: 12px 24px 12px 24px;
|
||||
}
|
||||
@@ -138,10 +141,8 @@ export class HaDialog extends DialogBase {
|
||||
flex-direction: column;
|
||||
}
|
||||
.header_title {
|
||||
position: relative;
|
||||
padding-right: 40px;
|
||||
padding-inline-end: 40px;
|
||||
padding-inline-start: initial;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
direction: var(--direction);
|
||||
}
|
||||
.header_title span {
|
||||
@@ -149,11 +150,9 @@ export class HaDialog extends DialogBase {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.header_button {
|
||||
position: absolute;
|
||||
right: -12px;
|
||||
top: -12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
inset-inline-start: initial;
|
||||
|
@@ -2,8 +2,11 @@ import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import { DEFAULT_DOMAIN_ICON, FIXED_DOMAIN_ICONS } from "../common/const";
|
||||
import { domainIcon } from "../data/icons";
|
||||
import {
|
||||
DEFAULT_DOMAIN_ICON,
|
||||
domainIcon,
|
||||
FALLBACK_DOMAIN_ICONS,
|
||||
} from "../data/icons";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { brandsUrl } from "../util/brands-url";
|
||||
import "./ha-icon";
|
||||
@@ -47,9 +50,9 @@ export class HaDomainIcon extends LitElement {
|
||||
}
|
||||
|
||||
private _renderFallback() {
|
||||
if (this.domain! in FIXED_DOMAIN_ICONS) {
|
||||
if (this.domain! in FALLBACK_DOMAIN_ICONS) {
|
||||
return html`
|
||||
<ha-svg-icon .path=${FIXED_DOMAIN_ICONS[this.domain!]}></ha-svg-icon>
|
||||
<ha-svg-icon .path=${FALLBACK_DOMAIN_ICONS[this.domain!]}></ha-svg-icon>
|
||||
`;
|
||||
}
|
||||
if (this.brandFallback) {
|
||||
|
@@ -19,6 +19,10 @@ export class HaFab extends FabBase {
|
||||
margin-inline-end: 12px;
|
||||
direction: var(--direction);
|
||||
}
|
||||
:disabled {
|
||||
--mdc-theme-secondary: var(--disabled-text-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
`,
|
||||
// safari workaround - must be explicit
|
||||
mainWindow.document.dir === "rtl"
|
||||
|
@@ -15,6 +15,7 @@ import { bytesToString } from "../util/bytes-to-string";
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"file-picked": { files: File[] };
|
||||
"files-cleared": void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +57,21 @@ export class HaFileUpload extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private get _name() {
|
||||
if (this.value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof this.value === "string") {
|
||||
return this.value;
|
||||
}
|
||||
const files =
|
||||
this.value instanceof FileList
|
||||
? Array.from(this.value)
|
||||
: ensureArray(this.value);
|
||||
|
||||
return files.map((file) => file.name).join(", ");
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
${this.uploading
|
||||
@@ -65,7 +81,7 @@ export class HaFileUpload extends LitElement {
|
||||
>${this.value
|
||||
? this.hass?.localize(
|
||||
"ui.components.file-upload.uploading_name",
|
||||
{ name: this.value.toString() }
|
||||
{ name: this._name }
|
||||
)
|
||||
: this.hass?.localize(
|
||||
"ui.components.file-upload.uploading"
|
||||
@@ -201,6 +217,7 @@ export class HaFileUpload extends LitElement {
|
||||
this._input!.value = "";
|
||||
this.value = undefined;
|
||||
fireEvent(this, "change");
|
||||
fireEvent(this, "files-cleared");
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
@@ -305,6 +322,15 @@ export class HaFileUpload extends LitElement {
|
||||
.progress {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
button.link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import "@material/mwc-list/mwc-list";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import type { List, SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -32,6 +32,8 @@ export class HaFilterStates extends LitElement {
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@query("mwc-list") private _list!: List;
|
||||
|
||||
protected render() {
|
||||
if (!this.states) {
|
||||
return nothing;
|
||||
@@ -84,12 +86,21 @@ export class HaFilterStates extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed) {
|
||||
protected willUpdate(changed) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
this._shouldRender = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changed) {
|
||||
if ((changed.has("expanded") || changed.has("states")) && this.expanded) {
|
||||
setTimeout(async () => {
|
||||
if (!this.expanded) return;
|
||||
this.renderRoot.querySelector("mwc-list")!.style.height =
|
||||
`${this.clientHeight - 49}px`;
|
||||
const list = this._list;
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
list.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
@@ -5,12 +5,14 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../ha-button-menu";
|
||||
import "../ha-check-list-item";
|
||||
import type { HaCheckListItem } from "../ha-check-list-item";
|
||||
import "../ha-checkbox";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import "../ha-formfield";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-textfield";
|
||||
import "../ha-md-button-menu";
|
||||
import "../ha-md-menu-item";
|
||||
|
||||
import type {
|
||||
HaFormElement,
|
||||
HaFormMultiSelectData,
|
||||
@@ -73,13 +75,10 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-button-menu
|
||||
<ha-md-button-menu
|
||||
.disabled=${this.disabled}
|
||||
fixed
|
||||
@opened=${this._handleOpen}
|
||||
@closed=${this._handleClose}
|
||||
multi
|
||||
activatable
|
||||
@opening=${this._handleOpen}
|
||||
@closing=${this._handleClose}
|
||||
>
|
||||
<ha-textfield
|
||||
slot="trigger"
|
||||
@@ -94,28 +93,56 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
.disabled=${this.disabled}
|
||||
tabindex="-1"
|
||||
></ha-textfield>
|
||||
<ha-svg-icon
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.label}
|
||||
.path=${this._opened ? mdiMenuUp : mdiMenuDown}
|
||||
></ha-svg-icon>
|
||||
></ha-icon-button>
|
||||
${options.map((item: string | [string, string]) => {
|
||||
const value = optionValue(item);
|
||||
const selected = data.includes(value);
|
||||
return html`<ha-check-list-item
|
||||
left
|
||||
.selected=${selected}
|
||||
.activated=${selected}
|
||||
@request-selected=${this._selectedChanged}
|
||||
return html`<ha-md-menu-item
|
||||
type="option"
|
||||
aria-checked=${selected}
|
||||
.value=${value}
|
||||
.disabled=${this.disabled}
|
||||
.action=${selected ? "remove" : "add"}
|
||||
.activated=${selected}
|
||||
@click=${this._toggleItem}
|
||||
@keydown=${this._keydown}
|
||||
keep-open
|
||||
>
|
||||
<ha-checkbox
|
||||
slot="start"
|
||||
tabindex="-1"
|
||||
.checked=${selected}
|
||||
></ha-checkbox>
|
||||
${optionLabel(item)}
|
||||
</ha-check-list-item>`;
|
||||
</ha-md-menu-item>`;
|
||||
})}
|
||||
</ha-button-menu>
|
||||
</ha-md-button-menu>
|
||||
`;
|
||||
}
|
||||
|
||||
protected _keydown(ev) {
|
||||
if (ev.code === "Space" || ev.code === "Enter") {
|
||||
ev.preventDefault();
|
||||
this._toggleItem(ev);
|
||||
}
|
||||
}
|
||||
|
||||
protected _toggleItem(ev) {
|
||||
const oldData = this.data || [];
|
||||
let newData: string[];
|
||||
if (ev.currentTarget.action === "add") {
|
||||
newData = [...oldData, ev.currentTarget.value];
|
||||
} else {
|
||||
newData = oldData.filter((d) => d !== ev.currentTarget.value);
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newData,
|
||||
});
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this.updateComplete.then(() => {
|
||||
const { formElement, mdcRoot } =
|
||||
@@ -139,17 +166,6 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _selectedChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
if (ev.detail.source === "property") {
|
||||
return;
|
||||
}
|
||||
this._handleValueChanged(
|
||||
(ev.target as HaCheckListItem).value,
|
||||
ev.detail.selected
|
||||
);
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
const { value, checked } = ev.target as HaCheckbox;
|
||||
this._handleValueChanged(value, checked);
|
||||
@@ -195,7 +211,7 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
:host([own-margin]) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
ha-button-menu {
|
||||
ha-md-button-menu {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -208,22 +224,23 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
}
|
||||
ha-textfield {
|
||||
display: block;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
ha-svg-icon {
|
||||
ha-icon-button {
|
||||
color: var(--input-dropdown-icon-color);
|
||||
position: absolute;
|
||||
right: 1em;
|
||||
top: 1em;
|
||||
top: 4px;
|
||||
cursor: pointer;
|
||||
inset-inline-end: 1em;
|
||||
inset-inline-start: initial;
|
||||
direction: var(--direction);
|
||||
}
|
||||
:host([opened]) ha-svg-icon {
|
||||
:host([opened]) ha-icon-button {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
:host([opened]) ha-button-menu {
|
||||
:host([opened]) ha-md-button-menu {
|
||||
--mdc-text-field-idle-line-color: var(--input-hover-line-color);
|
||||
--mdc-text-field-label-ink-color: var(--primary-color);
|
||||
}
|
||||
|
@@ -62,6 +62,7 @@ export class HaGauge extends LitElement {
|
||||
if (
|
||||
!this._updated ||
|
||||
(!changedProperties.has("value") &&
|
||||
!changedProperties.has("valueText") &&
|
||||
!changedProperties.has("label") &&
|
||||
!changedProperties.has("_segment_label"))
|
||||
) {
|
||||
|
@@ -181,7 +181,15 @@ class HaHLSPlayer extends LitElement {
|
||||
let playlist_url: string;
|
||||
if (match !== null && matchTwice === null) {
|
||||
// Only send the regular playlist url if we match exactly once
|
||||
playlist_url = new URL(match[3], this._url).href;
|
||||
// In case we arrive here with a relative URL, we need to provide a valid
|
||||
// base/absolute URL to avoid the URL() constructor throwing an error.
|
||||
let base_url: string;
|
||||
try {
|
||||
base_url = new URL(this._url).href;
|
||||
} catch (error) {
|
||||
base_url = new URL(this._url, window.location.href).href;
|
||||
}
|
||||
playlist_url = new URL(match[3], base_url).href;
|
||||
} else {
|
||||
playlist_url = this._url;
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { FOCUS_TARGET } from "../dialogs/make-dialog-manager";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HaIconButton } from "./ha-icon-button";
|
||||
import "./ha-menu";
|
||||
import type { HaMenu } from "./ha-menu";
|
||||
@@ -40,12 +41,22 @@ export class HaMdButtonMenu extends LitElement {
|
||||
<ha-menu
|
||||
.positioning=${this.positioning}
|
||||
.hasOverflow=${this.hasOverflow}
|
||||
@opening=${this._handleOpening}
|
||||
@closing=${this._handleClosing}
|
||||
>
|
||||
<slot></slot>
|
||||
</ha-menu>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleOpening(): void {
|
||||
fireEvent(this, "opening", undefined, { composed: false });
|
||||
}
|
||||
|
||||
private _handleClosing(): void {
|
||||
fireEvent(this, "closing", undefined, { composed: false });
|
||||
}
|
||||
|
||||
private _handleClick(): void {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
@@ -88,3 +99,10 @@ declare global {
|
||||
"ha-md-button-menu": HaMdButtonMenu;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
opening: undefined;
|
||||
closing: undefined;
|
||||
}
|
||||
}
|
||||
|
31
src/components/ha-md-textfield.ts
Normal file
31
src/components/ha-md-textfield.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { MdFilledTextField } from "@material/web/textfield/filled-text-field";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-md-textfield")
|
||||
export class HaMdTextfield extends MdFilledTextField {
|
||||
static override styles = [
|
||||
...super.styles,
|
||||
css`
|
||||
:host {
|
||||
--ha-icon-display: block;
|
||||
--md-sys-color-primary: var(--primary-text-color);
|
||||
--md-sys-color-secondary: var(--secondary-text-color);
|
||||
--md-sys-color-surface: var(--card-background-color);
|
||||
--md-sys-color-on-surface-variant: var(--secondary-text-color);
|
||||
|
||||
--md-sys-color-surface-container-highest: var(--input-fill-color);
|
||||
--md-sys-color-on-surface: var(--input-ink-color);
|
||||
|
||||
--md-sys-color-surface-container: var(--input-fill-color);
|
||||
--md-sys-color-secondary-container: var(--input-fill-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-md-textfield": HaMdTextfield;
|
||||
}
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
import { mdiBackupRestore, mdiFolder, mdiHarddisk, mdiPlayBox } from "@mdi/js";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
@@ -173,6 +174,16 @@ class HaMountPicker extends LitElement {
|
||||
fireEvent(this, "change");
|
||||
}, 0);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
ha-select {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
@@ -22,14 +22,6 @@ export class HaOutlinedField extends MdOutlinedField {
|
||||
border-end-start-radius: var(--_container-shape-end-start);
|
||||
border-end-end-radius: var(--_container-shape-end-end);
|
||||
}
|
||||
.with-start .start {
|
||||
margin-inline-end: var(--ha-outlined-field-start-margin, 4px);
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
.with-end .end {
|
||||
margin-inline-start: var(--ha-outlined-field-end-margin, 4px);
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@@ -28,8 +28,9 @@ export class HaOutlinedTextField extends MdOutlinedTextField {
|
||||
--md-outlined-field-container-shape-end-end: 10px;
|
||||
--md-outlined-field-container-shape-end-start: 10px;
|
||||
--md-outlined-field-focus-outline-width: 1px;
|
||||
--ha-outlined-field-start-margin: -4px;
|
||||
--ha-outlined-field-end-margin: -4px;
|
||||
--md-outlined-field-with-leading-content-leading-space: 8px;
|
||||
--md-outlined-field-with-trailing-content-trailing-space: 8px;
|
||||
--md-outlined-field-content-space: 8px;
|
||||
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
|
||||
}
|
||||
.input {
|
||||
|
@@ -143,6 +143,10 @@ export class HaPasswordField extends LitElement {
|
||||
></ha-icon-button>`;
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
this._textField.focus();
|
||||
}
|
||||
|
||||
public checkValidity(): boolean {
|
||||
return this._textField.checkValidity();
|
||||
}
|
||||
|
@@ -2,9 +2,16 @@ import { mdiImagePlus } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { MediaPickedEvent } from "../data/media-player";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import { createImage, generateImageThumbnailUrl } from "../data/image_upload";
|
||||
import {
|
||||
MEDIA_PREFIX,
|
||||
getIdFromUrl,
|
||||
createImage,
|
||||
generateImageThumbnailUrl,
|
||||
getImageData,
|
||||
} from "../data/image_upload";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
import type { CropOptions } from "../dialogs/image-cropper-dialog/show-image-cropper-dialog";
|
||||
import { showImageCropperDialog } from "../dialogs/image-cropper-dialog/show-image-cropper-dialog";
|
||||
@@ -12,6 +19,7 @@ import type { HomeAssistant } from "../types";
|
||||
import "./ha-button";
|
||||
import "./ha-circular-progress";
|
||||
import "./ha-file-upload";
|
||||
import { showMediaBrowserDialog } from "./media-player/show-media-browser-dialog";
|
||||
|
||||
@customElement("ha-picture-upload")
|
||||
export class HaPictureUpload extends LitElement {
|
||||
@@ -29,6 +37,9 @@ export class HaPictureUpload extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public crop = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "select-media" }) public selectMedia =
|
||||
false;
|
||||
|
||||
@property({ attribute: false }) public cropOptions?: CropOptions;
|
||||
|
||||
@property({ type: Boolean }) public original = false;
|
||||
@@ -39,13 +50,31 @@ export class HaPictureUpload extends LitElement {
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (!this.value) {
|
||||
const secondary =
|
||||
this.secondary ||
|
||||
(this.selectMedia
|
||||
? html`${this.hass.localize(
|
||||
"ui.components.picture-upload.secondary",
|
||||
{
|
||||
select_media: html`<button
|
||||
class="link"
|
||||
@click=${this._chooseMedia}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.picture-upload.select_media"
|
||||
)}
|
||||
</button>`,
|
||||
}
|
||||
)}`
|
||||
: undefined);
|
||||
|
||||
return html`
|
||||
<ha-file-upload
|
||||
.hass=${this.hass}
|
||||
.icon=${mdiImagePlus}
|
||||
.label=${this.label ||
|
||||
this.hass.localize("ui.components.picture-upload.label")}
|
||||
.secondary=${this.secondary}
|
||||
.secondary=${secondary}
|
||||
.supports=${this.supports ||
|
||||
this.hass.localize("ui.components.picture-upload.supported_formats")}
|
||||
.uploading=${this._uploading}
|
||||
@@ -66,7 +95,7 @@ export class HaPictureUpload extends LitElement {
|
||||
<ha-button
|
||||
@click=${this._handleChangeClick}
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.picture-upload.change_picture"
|
||||
"ui.components.picture-upload.clear_picture"
|
||||
)}
|
||||
>
|
||||
</ha-button>
|
||||
@@ -93,7 +122,7 @@ export class HaPictureUpload extends LitElement {
|
||||
this.value = null;
|
||||
}
|
||||
|
||||
private async _cropFile(file: File) {
|
||||
private async _cropFile(file: File, mediaId?: string) {
|
||||
if (!["image/png", "image/jpeg", "image/gif"].includes(file.type)) {
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
@@ -109,7 +138,16 @@ export class HaPictureUpload extends LitElement {
|
||||
aspectRatio: NaN,
|
||||
},
|
||||
croppedCallback: (croppedFile) => {
|
||||
this._uploadFile(croppedFile);
|
||||
if (mediaId && croppedFile === file) {
|
||||
this.value = generateImageThumbnailUrl(
|
||||
mediaId,
|
||||
this.size,
|
||||
this.original
|
||||
);
|
||||
fireEvent(this, "change");
|
||||
} else {
|
||||
this._uploadFile(croppedFile);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -141,6 +179,50 @@ export class HaPictureUpload extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _chooseMedia = () => {
|
||||
showMediaBrowserDialog(this, {
|
||||
action: "pick",
|
||||
entityId: "browser",
|
||||
navigateIds: [
|
||||
{ media_content_id: undefined, media_content_type: undefined },
|
||||
{
|
||||
media_content_id: MEDIA_PREFIX,
|
||||
media_content_type: "app",
|
||||
},
|
||||
],
|
||||
minimumNavigateLevel: 2,
|
||||
mediaPickedCallback: async (pickedMedia: MediaPickedEvent) => {
|
||||
const mediaId = getIdFromUrl(pickedMedia.item.media_content_id);
|
||||
if (mediaId) {
|
||||
if (this.crop) {
|
||||
const url = generateImageThumbnailUrl(mediaId, undefined, true);
|
||||
let data;
|
||||
try {
|
||||
data = await getImageData(url);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
text: err.toString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const metadata = {
|
||||
type: pickedMedia.item.media_content_type,
|
||||
};
|
||||
const file = new File([data], pickedMedia.item.title, metadata);
|
||||
this._cropFile(file, mediaId);
|
||||
} else {
|
||||
this.value = generateImageThumbnailUrl(
|
||||
mediaId,
|
||||
this.size,
|
||||
this.original
|
||||
);
|
||||
fireEvent(this, "change");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
|
@@ -164,7 +164,6 @@ export class HaRelatedItems extends LitElement {
|
||||
return html`
|
||||
<a
|
||||
href=${`/config/integrations/integration/${entry.domain}#config_entry=${entry.entry_id}`}
|
||||
@click=${this._navigateAwayClose}
|
||||
>
|
||||
<ha-list-item hasMeta graphic="icon">
|
||||
<img
|
||||
@@ -191,7 +190,6 @@ export class HaRelatedItems extends LitElement {
|
||||
(integration) =>
|
||||
html`<a
|
||||
href=${`/config/integrations/integration/${integration}`}
|
||||
@click=${this._navigateAwayClose}
|
||||
>
|
||||
<ha-list-item hasMeta graphic="icon">
|
||||
<img
|
||||
@@ -223,10 +221,7 @@ export class HaRelatedItems extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<a
|
||||
href="/config/devices/device/${relatedDeviceId}"
|
||||
@click=${this._navigateAwayClose}
|
||||
>
|
||||
<a href="/config/devices/device/${relatedDeviceId}">
|
||||
<ha-list-item hasMeta graphic="icon">
|
||||
<ha-svg-icon
|
||||
.path=${mdiDevices}
|
||||
@@ -251,10 +246,7 @@ export class HaRelatedItems extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<a
|
||||
href="/config/areas/area/${relatedAreaId}"
|
||||
@click=${this._navigateAwayClose}
|
||||
>
|
||||
<a href="/config/areas/area/${relatedAreaId}">
|
||||
<ha-list-item
|
||||
hasMeta
|
||||
.graphic=${area.picture ? "avatar" : "icon"}
|
||||
@@ -364,10 +356,7 @@ export class HaRelatedItems extends LitElement {
|
||||
const blueprintMeta = this._blueprints
|
||||
? this._blueprints.automation[path]
|
||||
: undefined;
|
||||
return html`<a
|
||||
href="/config/blueprint/dashboard"
|
||||
@click=${this._navigateAwayClose}
|
||||
>
|
||||
return html`<a href="/config/blueprint/dashboard">
|
||||
<ha-list-item hasMeta graphic="icon">
|
||||
<ha-svg-icon
|
||||
.path=${mdiPaletteSwatch}
|
||||
@@ -421,10 +410,7 @@ export class HaRelatedItems extends LitElement {
|
||||
const blueprintMeta = this._blueprints
|
||||
? this._blueprints.script[path]
|
||||
: undefined;
|
||||
return html`<a
|
||||
href="/config/blueprint/dashboard"
|
||||
@click=${this._navigateAwayClose}
|
||||
>
|
||||
return html`<a href="/config/blueprint/dashboard">
|
||||
<ha-list-item hasMeta graphic="icon">
|
||||
<ha-svg-icon
|
||||
.path=${mdiPaletteSwatch}
|
||||
@@ -468,14 +454,6 @@ export class HaRelatedItems extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async _navigateAwayClose() {
|
||||
// allow new page to open before closing dialog
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
fireEvent(this, "close-dialog");
|
||||
}
|
||||
|
||||
private async _findRelated() {
|
||||
this._related = await findRelated(this.hass, this.itemType, this.itemId);
|
||||
if (this._related.config_entry) {
|
||||
|
98
src/components/ha-selector/ha-selector-button-toggle.ts
Normal file
98
src/components/ha-selector/ha-selector-button-toggle.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import "@material/mwc-list/mwc-list-item";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import type { ButtonToggleSelector, SelectOption } from "../../data/selector";
|
||||
import type { HomeAssistant, ToggleButton } from "../../types";
|
||||
import "../ha-button-toggle-group";
|
||||
|
||||
@customElement("ha-selector-button_toggle")
|
||||
export class HaButtonToggleSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: ButtonToggleSelector;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public localizeValue?: (key: string) => string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
protected render() {
|
||||
const options =
|
||||
this.selector.button_toggle?.options?.map((option) =>
|
||||
typeof option === "object"
|
||||
? (option as SelectOption)
|
||||
: ({ value: option, label: option } as SelectOption)
|
||||
) || [];
|
||||
|
||||
const translationKey = this.selector.button_toggle?.translation_key;
|
||||
|
||||
if (this.localizeValue && translationKey) {
|
||||
options.forEach((option) => {
|
||||
const localizedLabel = this.localizeValue!(
|
||||
`${translationKey}.options.${option.value}`
|
||||
);
|
||||
if (localizedLabel) {
|
||||
option.label = localizedLabel;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.selector.button_toggle?.sort) {
|
||||
options.sort((a, b) =>
|
||||
caseInsensitiveStringCompare(
|
||||
a.label,
|
||||
b.label,
|
||||
this.hass.locale.language
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const toggleButtons: ToggleButton[] = options.map((item: SelectOption) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
}));
|
||||
|
||||
return html`
|
||||
${this.label}
|
||||
<ha-button-toggle-group
|
||||
.buttons=${toggleButtons}
|
||||
.active=${this.value}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-button-toggle-group>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const value = ev.detail?.value || ev.target.value;
|
||||
if (this.disabled || value === undefined || value === (this.value ?? "")) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-button_toggle": HaButtonToggleSelector;
|
||||
}
|
||||
}
|
@@ -96,6 +96,7 @@ export class HaImageSelector extends LitElement {
|
||||
.value=${this.value?.startsWith(URL_PREFIX) ? this.value : null}
|
||||
.original=${this.selector.image?.original}
|
||||
.cropOptions=${this.selector.image?.crop}
|
||||
select-media
|
||||
@change=${this._pictureChanged}
|
||||
></ha-picture-upload>
|
||||
`}
|
||||
|
@@ -51,6 +51,7 @@ const LOAD_ELEMENTS = {
|
||||
icon: () => import("./ha-selector-icon"),
|
||||
media: () => import("./ha-selector-media"),
|
||||
theme: () => import("./ha-selector-theme"),
|
||||
button_toggle: () => import("./ha-selector-button-toggle"),
|
||||
trigger: () => import("./ha-selector-trigger"),
|
||||
tts: () => import("./ha-selector-tts"),
|
||||
tts_voice: () => import("./ha-selector-tts-voice"),
|
||||
|
@@ -89,7 +89,7 @@ export class HaServiceControl extends LitElement {
|
||||
@property({ attribute: "show-advanced", type: Boolean }) public showAdvanced =
|
||||
false;
|
||||
|
||||
@property({ attribute: false, type: Boolean, reflect: true })
|
||||
@property({ attribute: "hide-picker", type: Boolean, reflect: true })
|
||||
public hidePicker = false;
|
||||
|
||||
@property({ attribute: "hide-description", type: Boolean })
|
||||
|
@@ -1,9 +1,12 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import { DEFAULT_SERVICE_ICON, FIXED_DOMAIN_ICONS } from "../common/const";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { serviceIcon } from "../data/icons";
|
||||
import {
|
||||
DEFAULT_SERVICE_ICON,
|
||||
FALLBACK_DOMAIN_ICONS,
|
||||
serviceIcon,
|
||||
} from "../data/icons";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-icon";
|
||||
import "./ha-svg-icon";
|
||||
@@ -44,7 +47,7 @@ export class HaServiceIcon extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
.path=${FIXED_DOMAIN_ICONS[domain] || DEFAULT_SERVICE_ICON}
|
||||
.path=${FALLBACK_DOMAIN_ICONS[domain] || DEFAULT_SERVICE_ICON}
|
||||
></ha-svg-icon>
|
||||
`;
|
||||
}
|
||||
|
@@ -48,6 +48,7 @@ import "./ha-menu-button";
|
||||
import "./ha-sortable";
|
||||
import "./ha-svg-icon";
|
||||
import "./user/ha-user-badge";
|
||||
import { preventDefault } from "../common/dom/prevent_default";
|
||||
|
||||
const SHOW_AFTER_SPACER = ["config", "developer-tools"];
|
||||
|
||||
@@ -404,6 +405,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
@focusout=${this._listboxFocusOut}
|
||||
@scroll=${this._listboxScroll}
|
||||
@keydown=${this._listboxKeydown}
|
||||
@iron-activate=${preventDefault}
|
||||
>
|
||||
${this.editMode
|
||||
? this._renderPanelsEdit(beforeSpacer)
|
||||
@@ -869,7 +871,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background-color: var(
|
||||
--sidebar-menu-button-background-color,
|
||||
var(--primary-background-color)
|
||||
inherit
|
||||
);
|
||||
font-size: 20px;
|
||||
align-items: center;
|
||||
|
@@ -2,9 +2,12 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import { DEFAULT_DOMAIN_ICON, FIXED_DOMAIN_ICONS } from "../common/const";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import { entityIcon } from "../data/icons";
|
||||
import {
|
||||
DEFAULT_DOMAIN_ICON,
|
||||
entityIcon,
|
||||
FALLBACK_DOMAIN_ICONS,
|
||||
} from "../data/icons";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-icon";
|
||||
import "./ha-svg-icon";
|
||||
@@ -49,7 +52,7 @@ export class HaStateIcon extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
.path=${FIXED_DOMAIN_ICONS[domain] || DEFAULT_DOMAIN_ICON}
|
||||
.path=${FALLBACK_DOMAIN_ICONS[domain] || DEFAULT_DOMAIN_ICON}
|
||||
></ha-svg-icon>
|
||||
`;
|
||||
}
|
||||
|
@@ -53,6 +53,12 @@ export class HaTextArea extends TextAreaBase {
|
||||
inset-inline-end: initial !important;
|
||||
transform-origin: var(--float-start) top;
|
||||
}
|
||||
@media only screen and (min-width: 459px) {
|
||||
:host([mobile-multiline]) .mdc-text-field__input {
|
||||
white-space: nowrap;
|
||||
max-height: 16px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@@ -207,6 +207,9 @@ export class HaTextField extends TextFieldBase {
|
||||
.mdc-text-field__affix--prefix {
|
||||
color: var(--mdc-text-field-label-ink-color);
|
||||
}
|
||||
#helper-text ha-markdown {
|
||||
display: inline-block;
|
||||
}
|
||||
`,
|
||||
// safari workaround - must be explicit
|
||||
mainWindow.document.dir === "rtl"
|
||||
|
@@ -1,9 +1,13 @@
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { property } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../ha-state-icon";
|
||||
|
||||
class HaEntityMarker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: "entity-id" }) public entityId?: string;
|
||||
|
||||
@property({ attribute: "entity-name" }) public entityName?: string;
|
||||
@@ -12,6 +16,8 @@ class HaEntityMarker extends LitElement {
|
||||
|
||||
@property({ attribute: "entity-color" }) public entityColor?: string;
|
||||
|
||||
@property({ attribute: "show-icon", type: Boolean }) public showIcon = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
@@ -26,7 +32,12 @@ class HaEntityMarker extends LitElement {
|
||||
"background-image": `url(${this.entityPicture})`,
|
||||
})}
|
||||
></div>`
|
||||
: this.entityName}
|
||||
: this.showIcon && this.entityId
|
||||
? html`<ha-state-icon
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.hass?.states[this.entityId]}
|
||||
></ha-state-icon>`
|
||||
: this.entityName}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
@@ -52,7 +52,7 @@ export interface HaMapPaths {
|
||||
export interface HaMapEntity {
|
||||
entity_id: string;
|
||||
color: string;
|
||||
label_mode?: "name" | "state";
|
||||
label_mode?: "name" | "state" | "icon";
|
||||
name?: string;
|
||||
focus?: boolean;
|
||||
}
|
||||
@@ -523,23 +523,24 @@ export class HaMap extends ReactiveElement {
|
||||
.join("")
|
||||
.substr(0, 3));
|
||||
|
||||
const entityMarker = document.createElement("ha-entity-marker");
|
||||
entityMarker.hass = this.hass;
|
||||
entityMarker.showIcon =
|
||||
typeof entity !== "string" && entity.label_mode === "icon";
|
||||
entityMarker.entityId = getEntityId(entity);
|
||||
entityMarker.entityName = entityName;
|
||||
entityMarker.entityPicture =
|
||||
entityPicture && (typeof entity === "string" || !entity.label_mode)
|
||||
? this.hass.hassUrl(entityPicture)
|
||||
: "";
|
||||
if (typeof entity !== "string") {
|
||||
entityMarker.entityColor = entity.color;
|
||||
}
|
||||
|
||||
// create marker with the icon
|
||||
const marker = Leaflet.marker([latitude, longitude], {
|
||||
icon: Leaflet.divIcon({
|
||||
html: `
|
||||
<ha-entity-marker
|
||||
entity-id="${getEntityId(entity)}"
|
||||
entity-name="${entityName}"
|
||||
entity-picture="${
|
||||
entityPicture ? this.hass.hassUrl(entityPicture) : ""
|
||||
}"
|
||||
${
|
||||
typeof entity !== "string"
|
||||
? `entity-color="${entity.color}"`
|
||||
: ""
|
||||
}
|
||||
></ha-entity-marker>
|
||||
`,
|
||||
html: entityMarker,
|
||||
iconSize: [48, 48],
|
||||
className: "",
|
||||
}),
|
||||
|
@@ -13,7 +13,10 @@ import { MediaClassBrowserSettings } from "../../data/media-player";
|
||||
import {
|
||||
browseLocalMediaPlayer,
|
||||
removeLocalMedia,
|
||||
isLocalMediaSourceContentId,
|
||||
isImageUploadMediaSourceContentId,
|
||||
} from "../../data/media_source";
|
||||
import { deleteImage, getIdFromUrl } from "../../data/image_upload";
|
||||
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -114,7 +117,7 @@ class DialogMediaManage extends LitElement {
|
||||
: html`
|
||||
<ha-button
|
||||
class="danger"
|
||||
slot="title"
|
||||
slot="navigationIcon"
|
||||
.disabled=${this._deleting}
|
||||
.label=${this.hass.localize(
|
||||
`ui.components.media-browser.file_management.${
|
||||
@@ -207,12 +210,10 @@ class DialogMediaManage extends LitElement {
|
||||
href="/config/storage"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass
|
||||
.localize(
|
||||
"ui.components.media-browser.file_management.tip_storage_panel"
|
||||
)
|
||||
.toLowerCase()}
|
||||
</a>`,
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.file_management.tip_storage_panel"
|
||||
)}</a
|
||||
>`,
|
||||
}
|
||||
)}
|
||||
</ha-tip>`
|
||||
@@ -270,7 +271,14 @@ class DialogMediaManage extends LitElement {
|
||||
try {
|
||||
await Promise.all(
|
||||
toDelete.map(async (item) => {
|
||||
await removeLocalMedia(this.hass, item.media_content_id);
|
||||
if (isLocalMediaSourceContentId(item.media_content_id)) {
|
||||
await removeLocalMedia(this.hass, item.media_content_id);
|
||||
} else if (isImageUploadMediaSourceContentId(item.media_content_id)) {
|
||||
const media_id = getIdFromUrl(item.media_content_id);
|
||||
if (media_id) {
|
||||
await deleteImage(this.hass, media_id);
|
||||
}
|
||||
}
|
||||
this._currentItem = {
|
||||
...this._currentItem!,
|
||||
children: this._currentItem!.children!.filter((i) => i !== item),
|
||||
|
@@ -85,7 +85,7 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
@opened=${this._dialogOpened}
|
||||
>
|
||||
<ha-dialog-header show-border slot="heading">
|
||||
${this._navigateIds.length > 1
|
||||
${this._navigateIds.length > (this._params.minimumNavigateLevel ?? 1)
|
||||
? html`
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
|
@@ -4,7 +4,10 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { MediaPlayerItem } from "../../data/media-player";
|
||||
import { isLocalMediaSourceContentId } from "../../data/media_source";
|
||||
import {
|
||||
isLocalMediaSourceContentId,
|
||||
isImageUploadMediaSourceContentId,
|
||||
} from "../../data/media_source";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-svg-icon";
|
||||
import { showMediaManageDialog } from "./show-media-manage-dialog";
|
||||
@@ -26,7 +29,11 @@ class MediaManageButton extends LitElement {
|
||||
protected render() {
|
||||
if (
|
||||
!this.currentItem ||
|
||||
!isLocalMediaSourceContentId(this.currentItem.media_content_id || "")
|
||||
!(
|
||||
isLocalMediaSourceContentId(this.currentItem.media_content_id || "") ||
|
||||
(this.hass!.user?.is_admin &&
|
||||
isImageUploadMediaSourceContentId(this.currentItem.media_content_id))
|
||||
)
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
@@ -10,6 +10,7 @@ export interface MediaPlayerBrowseDialogParams {
|
||||
entityId: string;
|
||||
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => void;
|
||||
navigateIds?: MediaPlayerItemId[];
|
||||
minimumNavigateLevel?: number;
|
||||
}
|
||||
|
||||
export const showMediaBrowserDialog = (
|
||||
|
@@ -20,8 +20,8 @@ export class HatGraphNode extends LitElement {
|
||||
@property({ attribute: false, reflect: true, type: Boolean }) notEnabled =
|
||||
false;
|
||||
|
||||
@property({ attribute: false, reflect: true, type: Boolean }) graphStart =
|
||||
false;
|
||||
@property({ attribute: "graph-start", reflect: true, type: Boolean })
|
||||
graphStart = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "nofocus" }) noFocus = false;
|
||||
|
||||
@@ -112,7 +112,7 @@ export class HatGraphNode extends LitElement {
|
||||
var(--hat-graph-node-size) + var(--hat-graph-spacing) + 1px
|
||||
);
|
||||
}
|
||||
:host([graphStart]) {
|
||||
:host([graph-start]) {
|
||||
height: calc(var(--hat-graph-node-size) + 2px);
|
||||
}
|
||||
:host([track]) {
|
||||
|
@@ -91,7 +91,7 @@ export class HatScriptGraph extends LitElement {
|
||||
}
|
||||
return html`
|
||||
<hat-graph-node
|
||||
graphStart
|
||||
graph-start
|
||||
?track=${track}
|
||||
@focus=${this._selectNode(config, path)}
|
||||
?active=${this.selected === path}
|
||||
@@ -354,8 +354,8 @@ export class HatScriptGraph extends LitElement {
|
||||
></hat-graph-node>
|
||||
<div
|
||||
style=${`width: ${NODE_SIZE + SPACING}px;`}
|
||||
graphStart
|
||||
graphEnd
|
||||
graph-start
|
||||
graph-end
|
||||
></div>
|
||||
<div ?track=${trackPass}></div>
|
||||
<hat-graph-node
|
||||
|
@@ -737,18 +737,22 @@ const tryDescribeTrigger = (
|
||||
? computeStateName(hass.states[trigger.entity_id])
|
||||
: trigger.entity_id;
|
||||
|
||||
let offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
|
||||
let offset: string | string[] = trigger.offset.startsWith("-")
|
||||
? trigger.offset.substring(1).split(":")
|
||||
: trigger.offset.split(":");
|
||||
const duration = {
|
||||
hours: offset.length > 0 ? +offset[0] : 0,
|
||||
minutes: offset.length > 1 ? +offset[1] : 0,
|
||||
seconds: offset.length > 2 ? +offset[2] : 0,
|
||||
};
|
||||
offset = formatDurationLong(hass.locale, duration);
|
||||
if (offset === "") {
|
||||
offsetChoice = "other";
|
||||
let offsetChoice: string = "other";
|
||||
let offset: string | string[] = "";
|
||||
if (trigger.offset) {
|
||||
offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
|
||||
offset = trigger.offset.startsWith("-")
|
||||
? trigger.offset.substring(1).split(":")
|
||||
: trigger.offset.split(":");
|
||||
const duration = {
|
||||
hours: offset.length > 0 ? +offset[0] : 0,
|
||||
minutes: offset.length > 1 ? +offset[1] : 0,
|
||||
seconds: offset.length > 2 ? +offset[2] : 0,
|
||||
};
|
||||
offset = formatDurationLong(hass.locale, duration);
|
||||
if (offset === "") {
|
||||
offsetChoice = "other";
|
||||
}
|
||||
}
|
||||
|
||||
return hass.localize(
|
||||
|
@@ -1,36 +1,340 @@
|
||||
import { setHours, setMinutes } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { formatTime } from "../common/datetime/format_time";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { domainToName } from "./integration";
|
||||
import type { FrontendLocaleData } from "./translation";
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDateTimeNumeric,
|
||||
} from "../common/datetime/format_date_time";
|
||||
import { fileDownload } from "../util/file_download";
|
||||
|
||||
export const enum BackupScheduleState {
|
||||
NEVER = "never",
|
||||
DAILY = "daily",
|
||||
MONDAY = "mon",
|
||||
TUESDAY = "tue",
|
||||
WEDNESDAY = "wed",
|
||||
THURSDAY = "thu",
|
||||
FRIDAY = "fri",
|
||||
SATURDAY = "sat",
|
||||
SUNDAY = "sun",
|
||||
}
|
||||
|
||||
export interface BackupConfig {
|
||||
last_attempted_automatic_backup: string | null;
|
||||
last_completed_automatic_backup: string | null;
|
||||
create_backup: {
|
||||
agent_ids: string[];
|
||||
include_addons: string[] | null;
|
||||
include_all_addons: boolean;
|
||||
include_database: boolean;
|
||||
include_folders: string[] | null;
|
||||
name: string | null;
|
||||
password: string | null;
|
||||
};
|
||||
retention: {
|
||||
copies?: number | null;
|
||||
days?: number | null;
|
||||
};
|
||||
schedule: {
|
||||
state: BackupScheduleState;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BackupMutableConfig {
|
||||
create_backup?: {
|
||||
agent_ids?: string[];
|
||||
include_addons?: string[];
|
||||
include_all_addons?: boolean;
|
||||
include_database?: boolean;
|
||||
include_folders?: string[];
|
||||
name?: string | null;
|
||||
password?: string | null;
|
||||
};
|
||||
retention?: {
|
||||
copies?: number | null;
|
||||
days?: number | null;
|
||||
};
|
||||
schedule?: BackupScheduleState;
|
||||
}
|
||||
|
||||
export interface BackupAgent {
|
||||
agent_id: string;
|
||||
}
|
||||
|
||||
export interface BackupContent {
|
||||
slug: string;
|
||||
backup_id: string;
|
||||
date: string;
|
||||
name: string;
|
||||
protected: boolean;
|
||||
size: number;
|
||||
path: string;
|
||||
agent_ids?: string[];
|
||||
failed_agent_ids?: string[];
|
||||
with_automatic_settings: boolean;
|
||||
}
|
||||
|
||||
export interface BackupData {
|
||||
backing_up: boolean;
|
||||
backups: BackupContent[];
|
||||
addons: BackupAddon[];
|
||||
database_included: boolean;
|
||||
folders: string[];
|
||||
homeassistant_version: string;
|
||||
homeassistant_included: boolean;
|
||||
}
|
||||
|
||||
export const getBackupDownloadUrl = (slug: string) =>
|
||||
`/api/backup/download/${slug}`;
|
||||
export interface BackupAddon {
|
||||
name: string;
|
||||
slug: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export const fetchBackupInfo = (hass: HomeAssistant): Promise<BackupData> =>
|
||||
export interface BackupContentExtended extends BackupContent, BackupData {}
|
||||
|
||||
export interface BackupInfo {
|
||||
backups: BackupContent[];
|
||||
backing_up: boolean;
|
||||
}
|
||||
|
||||
export interface BackupDetails {
|
||||
backup: BackupContentExtended;
|
||||
}
|
||||
|
||||
export interface BackupAgentsInfo {
|
||||
agents: BackupAgent[];
|
||||
}
|
||||
|
||||
export type GenerateBackupParams = {
|
||||
agent_ids: string[];
|
||||
include_addons?: string[];
|
||||
include_all_addons?: boolean;
|
||||
include_database?: boolean;
|
||||
include_folders?: string[];
|
||||
include_homeassistant?: boolean;
|
||||
name?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type RestoreBackupParams = {
|
||||
backup_id: string;
|
||||
agent_id: string;
|
||||
password?: string;
|
||||
restore_addons?: string[];
|
||||
restore_database?: boolean;
|
||||
restore_folders?: string[];
|
||||
restore_homeassistant?: boolean;
|
||||
};
|
||||
|
||||
export const fetchBackupConfig = (hass: HomeAssistant) =>
|
||||
hass.callWS<{ config: BackupConfig }>({ type: "backup/config/info" });
|
||||
|
||||
export const updateBackupConfig = (
|
||||
hass: HomeAssistant,
|
||||
config: BackupMutableConfig
|
||||
) => hass.callWS({ type: "backup/config/update", ...config });
|
||||
|
||||
export const getBackupDownloadUrl = (id: string, agentId: string) =>
|
||||
`/api/backup/download/${id}?agent_id=${agentId}`;
|
||||
|
||||
export const fetchBackupInfo = (hass: HomeAssistant): Promise<BackupInfo> =>
|
||||
hass.callWS({
|
||||
type: "backup/info",
|
||||
});
|
||||
|
||||
export const removeBackup = (
|
||||
export const fetchBackupDetails = (
|
||||
hass: HomeAssistant,
|
||||
slug: string
|
||||
): Promise<void> =>
|
||||
id: string
|
||||
): Promise<BackupDetails> =>
|
||||
hass.callWS({
|
||||
type: "backup/remove",
|
||||
slug,
|
||||
type: "backup/details",
|
||||
backup_id: id,
|
||||
});
|
||||
|
||||
export const generateBackup = (hass: HomeAssistant): Promise<BackupContent> =>
|
||||
export const fetchBackupAgentsInfo = (
|
||||
hass: HomeAssistant
|
||||
): Promise<BackupAgentsInfo> =>
|
||||
hass.callWS({
|
||||
type: "backup/agents/info",
|
||||
});
|
||||
|
||||
export const deleteBackup = (hass: HomeAssistant, id: string): Promise<void> =>
|
||||
hass.callWS({
|
||||
type: "backup/delete",
|
||||
backup_id: id,
|
||||
});
|
||||
|
||||
export const generateBackup = (
|
||||
hass: HomeAssistant,
|
||||
params: GenerateBackupParams
|
||||
): Promise<{ backup_id: string }> =>
|
||||
hass.callWS({
|
||||
type: "backup/generate",
|
||||
...params,
|
||||
});
|
||||
|
||||
export const generateBackupWithAutomaticSettings = (
|
||||
hass: HomeAssistant
|
||||
): Promise<void> =>
|
||||
hass.callWS({
|
||||
type: "backup/generate_with_automatic_settings",
|
||||
});
|
||||
|
||||
export const restoreBackup = (
|
||||
hass: HomeAssistant,
|
||||
params: RestoreBackupParams
|
||||
): Promise<{ backup_id: string }> =>
|
||||
hass.callWS({
|
||||
type: "backup/restore",
|
||||
...params,
|
||||
});
|
||||
|
||||
export const uploadBackup = async (
|
||||
hass: HomeAssistant,
|
||||
file: File,
|
||||
agent_ids: string[]
|
||||
): Promise<void> => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
|
||||
const params = agent_ids.reduce((acc, agent_id) => {
|
||||
acc.append("agent_id", agent_id);
|
||||
return acc;
|
||||
}, new URLSearchParams());
|
||||
|
||||
const resp = await hass.fetchWithAuth(
|
||||
`/api/backup/upload?${params.toString()}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: fd,
|
||||
}
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const getPreferredAgentForDownload = (agents: string[]) => {
|
||||
const localAgents = agents.filter(
|
||||
(agent) => agent.split(".")[0] === "backup"
|
||||
);
|
||||
return localAgents[0] || agents[0];
|
||||
};
|
||||
|
||||
export const CORE_LOCAL_AGENT = "backup.local";
|
||||
export const HASSIO_LOCAL_AGENT = "hassio.local";
|
||||
export const CLOUD_AGENT = "cloud.cloud";
|
||||
|
||||
export const isLocalAgent = (agentId: string) =>
|
||||
[CORE_LOCAL_AGENT, HASSIO_LOCAL_AGENT].includes(agentId);
|
||||
|
||||
export const isNetworkMountAgent = (agentId: string) => {
|
||||
const [domain, name] = agentId.split(".");
|
||||
return domain === "hassio" && name !== "local";
|
||||
};
|
||||
|
||||
export const computeBackupAgentName = (
|
||||
localize: LocalizeFunc,
|
||||
agentId: string,
|
||||
agentIds?: string[]
|
||||
) => {
|
||||
if (isLocalAgent(agentId)) {
|
||||
return "This system";
|
||||
}
|
||||
const [domain, name] = agentId.split(".");
|
||||
|
||||
if (isNetworkMountAgent(agentId)) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const domainName = domainToName(localize, domain);
|
||||
|
||||
// If there are multiple agents for a domain, show the name
|
||||
const showName = agentIds
|
||||
? agentIds.filter((a) => a.split(".")[0] === domain).length > 1
|
||||
: true;
|
||||
|
||||
return showName ? `${domainName}: ${name}` : domainName;
|
||||
};
|
||||
|
||||
export const compareAgents = (a: string, b: string) => {
|
||||
const isLocalA = isLocalAgent(a);
|
||||
const isLocalB = isLocalAgent(b);
|
||||
const isNetworkMountAgentA = isNetworkMountAgent(a);
|
||||
const isNetworkMountAgentB = isNetworkMountAgent(b);
|
||||
|
||||
const getPriority = (isLocal: boolean, isNetworkMount: boolean) => {
|
||||
if (isLocal) return 1;
|
||||
if (isNetworkMount) return 2;
|
||||
return 3;
|
||||
};
|
||||
|
||||
const priorityA = getPriority(isLocalA, isNetworkMountAgentA);
|
||||
const priorityB = getPriority(isLocalB, isNetworkMountAgentB);
|
||||
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
};
|
||||
|
||||
export const generateEncryptionKey = () => {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
const pattern = "xxxx-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx";
|
||||
let result = "";
|
||||
const randomArray = new Uint8Array(pattern.length);
|
||||
crypto.getRandomValues(randomArray);
|
||||
randomArray.forEach((number, index) => {
|
||||
result += pattern[index] === "-" ? "-" : chars[number % chars.length];
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const generateEmergencyKit = (
|
||||
hass: HomeAssistant,
|
||||
encryptionKey: string
|
||||
) =>
|
||||
"data:text/plain;charset=utf-8," +
|
||||
encodeURIComponent(`Home Assistant Backup Emergency Kit
|
||||
|
||||
This emergency kit contains your backup encryption key. You need this key
|
||||
to be able to restore your Home Assistant backups.
|
||||
|
||||
Date: ${formatDateTime(new Date(), hass.locale, hass.config)}
|
||||
|
||||
Instance:
|
||||
${hass.config.location_name}
|
||||
|
||||
URL:
|
||||
${hass.auth.data.hassUrl}
|
||||
|
||||
Encryption key:
|
||||
${encryptionKey}
|
||||
|
||||
For more information visit: https://www.home-assistant.io/more-info/backup-emergency-kit`);
|
||||
|
||||
export const geneateEmergencyKitFileName = (
|
||||
hass: HomeAssistant,
|
||||
append?: string
|
||||
) =>
|
||||
`home_assistant_backup_emergency_kit_${append ? `${append}_` : ""}${formatDateTimeNumeric(new Date(), hass.locale, hass.config).replace(",", "").replace(" ", "_")}.txt`;
|
||||
|
||||
export const downloadEmergencyKit = (
|
||||
hass: HomeAssistant,
|
||||
key: string,
|
||||
appendFileName?: string
|
||||
) =>
|
||||
fileDownload(
|
||||
generateEmergencyKit(hass, key),
|
||||
geneateEmergencyKitFileName(hass, appendFileName)
|
||||
);
|
||||
|
||||
export const getFormattedBackupTime = memoizeOne(
|
||||
(locale: FrontendLocaleData, config: HassConfig) => {
|
||||
const date = setMinutes(setHours(new Date(), 4), 45);
|
||||
return formatTime(date, locale, config);
|
||||
}
|
||||
);
|
||||
|
77
src/data/backup_manager.ts
Normal file
77
src/data/backup_manager.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export type BackupManagerState =
|
||||
| "idle"
|
||||
| "create_backup"
|
||||
| "receive_backup"
|
||||
| "restore_backup";
|
||||
|
||||
export type CreateBackupStage =
|
||||
| "addon_repositories"
|
||||
| "addons"
|
||||
| "await_addon_restarts"
|
||||
| "docker_config"
|
||||
| "finishing_file"
|
||||
| "folders"
|
||||
| "home_assistant"
|
||||
| "upload_to_agents";
|
||||
|
||||
export type CreateBackupState = "completed" | "failed" | "in_progress";
|
||||
|
||||
export type ReceiveBackupStage = "receive_file" | "upload_to_agents";
|
||||
|
||||
export type ReceiveBackupState = "completed" | "failed" | "in_progress";
|
||||
|
||||
export type RestoreBackupStage =
|
||||
| "addon_repositories"
|
||||
| "addons"
|
||||
| "await_addon_restarts"
|
||||
| "await_home_assistant_restart"
|
||||
| "check_home_assistant"
|
||||
| "docker_config"
|
||||
| "download_from_agent"
|
||||
| "folders"
|
||||
| "home_assistant"
|
||||
| "remove_delta_addons";
|
||||
|
||||
export type RestoreBackupState = "completed" | "failed" | "in_progress";
|
||||
|
||||
type IdleEvent = {
|
||||
manager_state: "idle";
|
||||
};
|
||||
|
||||
type CreateBackupEvent = {
|
||||
manager_state: "create_backup";
|
||||
stage: CreateBackupStage | null;
|
||||
state: CreateBackupState;
|
||||
};
|
||||
|
||||
type ReceiveBackupEvent = {
|
||||
manager_state: "receive_backup";
|
||||
stage: ReceiveBackupStage | null;
|
||||
state: ReceiveBackupState;
|
||||
};
|
||||
|
||||
type RestoreBackupEvent = {
|
||||
manager_state: "restore_backup";
|
||||
stage: RestoreBackupStage | null;
|
||||
state: RestoreBackupState;
|
||||
};
|
||||
|
||||
export type ManagerStateEvent =
|
||||
| IdleEvent
|
||||
| CreateBackupEvent
|
||||
| ReceiveBackupEvent
|
||||
| RestoreBackupEvent;
|
||||
|
||||
export const subscribeBackupEvents = (
|
||||
hass: HomeAssistant,
|
||||
callback: (event: ManagerStateEvent) => void
|
||||
) =>
|
||||
hass.connection.subscribeMessage<ManagerStateEvent>(callback, {
|
||||
type: "backup/subscribe_events",
|
||||
});
|
||||
|
||||
export const DEFAULT_MANAGER_STATE: ManagerStateEvent = {
|
||||
manager_state: "idle",
|
||||
};
|
@@ -70,18 +70,27 @@ export interface CloudWebhook {
|
||||
managed?: boolean;
|
||||
}
|
||||
|
||||
export const cloudLogin = (
|
||||
hass: HomeAssistant,
|
||||
email: string,
|
||||
password: string
|
||||
) =>
|
||||
interface CloudLoginBase {
|
||||
hass: HomeAssistant;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface CloudLoginPassword extends CloudLoginBase {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface CloudLoginMFA extends CloudLoginBase {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export const cloudLogin = ({
|
||||
hass,
|
||||
...rest
|
||||
}: CloudLoginPassword | CloudLoginMFA) =>
|
||||
hass.callApi<{ success: boolean; cloud_pipeline?: string }>(
|
||||
"POST",
|
||||
"cloud/login",
|
||||
{
|
||||
email,
|
||||
password,
|
||||
}
|
||||
rest
|
||||
);
|
||||
|
||||
export const cloudLogout = (hass: HomeAssistant) =>
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { handleFetchPromise } from "../../util/hass-call-api";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
@@ -105,11 +106,13 @@ export const fetchHassioBackupInfo = async (
|
||||
);
|
||||
}
|
||||
// When called from onboarding we don't have hass
|
||||
const resp = await fetch(`/api/hassio/backups/${backup}/info`, {
|
||||
method: "GET",
|
||||
});
|
||||
const data = (await resp.json()).data;
|
||||
return data;
|
||||
return hassioApiResultExtractor(
|
||||
await handleFetchPromise(
|
||||
fetch(`/api/hassio/backups/${backup}/info`, {
|
||||
method: "GET",
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const reloadHassioBackups = async (hass: HomeAssistant) => {
|
||||
@@ -236,3 +239,26 @@ export const uploadBackup = async (
|
||||
}
|
||||
return resp.json();
|
||||
};
|
||||
|
||||
export const restoreBackup = async (
|
||||
hass: HomeAssistant | undefined,
|
||||
type: HassioBackupDetail["type"],
|
||||
backupSlug: string,
|
||||
backupDetails: HassioPartialBackupCreateParams | HassioFullBackupCreateParams,
|
||||
useSnapshotUrl: boolean
|
||||
): Promise<void> => {
|
||||
if (hass) {
|
||||
await hass.callApi<HassioResponse<{ job_id: string }>>(
|
||||
"POST",
|
||||
`hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`,
|
||||
backupDetails
|
||||
);
|
||||
} else {
|
||||
await handleFetchPromise(
|
||||
fetch(`/api/hassio/backups/${backupSlug}/restore/${type}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(backupDetails),
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@@ -118,7 +118,9 @@ export const accesspointScan = async (
|
||||
|
||||
export const parseAddress = (address: string) => {
|
||||
const [ip, cidr] = address.split("/");
|
||||
return { ip, mask: cidrToNetmask(cidr, address.includes(":")) };
|
||||
const isIPv6 = ip.includes(":");
|
||||
const mask = cidr ? cidrToNetmask(cidr, isIPv6) : null;
|
||||
return { ip, mask };
|
||||
};
|
||||
|
||||
export const formatAddress = (ip: string, mask: string) =>
|
||||
|
@@ -1,4 +1,51 @@
|
||||
import {
|
||||
mdiAccount,
|
||||
mdiAirFilter,
|
||||
mdiAlert,
|
||||
mdiAppleSafari,
|
||||
mdiBell,
|
||||
mdiBookmark,
|
||||
mdiBullhorn,
|
||||
mdiButtonPointer,
|
||||
mdiCalendar,
|
||||
mdiCalendarClock,
|
||||
mdiChatSleep,
|
||||
mdiClipboardList,
|
||||
mdiClock,
|
||||
mdiCog,
|
||||
mdiCommentAlert,
|
||||
mdiCounter,
|
||||
mdiEye,
|
||||
mdiFlower,
|
||||
mdiFormatListBulleted,
|
||||
mdiFormTextbox,
|
||||
mdiForumOutline,
|
||||
mdiGoogleAssistant,
|
||||
mdiGoogleCirclesCommunities,
|
||||
mdiHomeAutomation,
|
||||
mdiImage,
|
||||
mdiImageFilterFrames,
|
||||
mdiLightbulb,
|
||||
mdiMapMarkerRadius,
|
||||
mdiMicrophoneMessage,
|
||||
mdiPalette,
|
||||
mdiRayVertex,
|
||||
mdiRemote,
|
||||
mdiRobot,
|
||||
mdiRobotMower,
|
||||
mdiRobotVacuum,
|
||||
mdiRoomService,
|
||||
mdiScriptText,
|
||||
mdiSpeakerMessage,
|
||||
mdiThermostat,
|
||||
mdiTimerOutline,
|
||||
mdiToggleSwitch,
|
||||
mdiWeatherPartlyCloudy,
|
||||
mdiWhiteBalanceSunny,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { atLeastVersion } from "../common/config/version";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeObjectId } from "../common/entity/compute_object_id";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
@@ -8,8 +55,69 @@ import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
EntityRegistryEntry,
|
||||
} from "./entity_registry";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { atLeastVersion } from "../common/config/version";
|
||||
|
||||
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
|
||||
|
||||
/** Icon to use when no icon specified for service. */
|
||||
export const DEFAULT_SERVICE_ICON = mdiRoomService;
|
||||
|
||||
/** Icon to use when no icon specified for domain. */
|
||||
export const DEFAULT_DOMAIN_ICON = mdiBookmark;
|
||||
|
||||
/** Fallback icons for each domain */
|
||||
export const FALLBACK_DOMAIN_ICONS = {
|
||||
air_quality: mdiAirFilter,
|
||||
alert: mdiAlert,
|
||||
automation: mdiRobot,
|
||||
calendar: mdiCalendar,
|
||||
climate: mdiThermostat,
|
||||
configurator: mdiCog,
|
||||
conversation: mdiForumOutline,
|
||||
counter: mdiCounter,
|
||||
date: mdiCalendar,
|
||||
datetime: mdiCalendarClock,
|
||||
demo: mdiHomeAssistant,
|
||||
device_tracker: mdiAccount,
|
||||
google_assistant: mdiGoogleAssistant,
|
||||
group: mdiGoogleCirclesCommunities,
|
||||
homeassistant: mdiHomeAssistant,
|
||||
homekit: mdiHomeAutomation,
|
||||
image_processing: mdiImageFilterFrames,
|
||||
image: mdiImage,
|
||||
input_boolean: mdiToggleSwitch,
|
||||
input_button: mdiButtonPointer,
|
||||
input_datetime: mdiCalendarClock,
|
||||
input_number: mdiRayVertex,
|
||||
input_select: mdiFormatListBulleted,
|
||||
input_text: mdiFormTextbox,
|
||||
lawn_mower: mdiRobotMower,
|
||||
light: mdiLightbulb,
|
||||
notify: mdiCommentAlert,
|
||||
number: mdiRayVertex,
|
||||
persistent_notification: mdiBell,
|
||||
person: mdiAccount,
|
||||
plant: mdiFlower,
|
||||
proximity: mdiAppleSafari,
|
||||
remote: mdiRemote,
|
||||
scene: mdiPalette,
|
||||
schedule: mdiCalendarClock,
|
||||
script: mdiScriptText,
|
||||
select: mdiFormatListBulleted,
|
||||
sensor: mdiEye,
|
||||
simple_alarm: mdiBell,
|
||||
siren: mdiBullhorn,
|
||||
stt: mdiMicrophoneMessage,
|
||||
sun: mdiWhiteBalanceSunny,
|
||||
text: mdiFormTextbox,
|
||||
time: mdiClock,
|
||||
timer: mdiTimerOutline,
|
||||
todo: mdiClipboardList,
|
||||
tts: mdiSpeakerMessage,
|
||||
vacuum: mdiRobotVacuum,
|
||||
wake_word: mdiChatSleep,
|
||||
weather: mdiWeatherPartlyCloudy,
|
||||
zone: mdiMapMarkerRadius,
|
||||
};
|
||||
|
||||
const resources: {
|
||||
entity: Record<string, Promise<PlatformIcons>>;
|
||||
|
@@ -9,6 +9,7 @@ interface Image {
|
||||
}
|
||||
|
||||
export const URL_PREFIX = "/api/image/serve/";
|
||||
export const MEDIA_PREFIX = "media-source://image_upload";
|
||||
|
||||
export interface ImageMutableParams {
|
||||
name: string;
|
||||
@@ -22,6 +23,8 @@ export const getIdFromUrl = (url: string): string | undefined => {
|
||||
if (idx >= 0) {
|
||||
id = id.substring(0, idx);
|
||||
}
|
||||
} else if (url.startsWith(MEDIA_PREFIX)) {
|
||||
id = url.substring(MEDIA_PREFIX.length + 1);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
@@ -77,3 +80,17 @@ export const deleteImage = (hass: HomeAssistant, id: string) =>
|
||||
type: "image/delete",
|
||||
image_id: id,
|
||||
});
|
||||
|
||||
export const getImageData = async (url: string) => {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch image: ${
|
||||
response.statusText ? response.statusText : response.status
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
};
|
||||
|
@@ -7,8 +7,22 @@ export interface ShowViewConfig {
|
||||
user?: string;
|
||||
}
|
||||
|
||||
interface LovelaceViewBackgroundConfig {
|
||||
export interface LovelaceViewBackgroundConfig {
|
||||
image?: string;
|
||||
opacity?: number;
|
||||
size?: "auto" | "cover" | "contain";
|
||||
alignment?:
|
||||
| "top left"
|
||||
| "top center"
|
||||
| "top right"
|
||||
| "center left"
|
||||
| "center"
|
||||
| "center right"
|
||||
| "bottom left"
|
||||
| "bottom center"
|
||||
| "bottom right";
|
||||
repeat?: "repeat" | "no-repeat";
|
||||
attachment?: "scroll" | "fixed";
|
||||
}
|
||||
|
||||
export interface LovelaceBaseViewConfig {
|
||||
|
@@ -27,6 +27,9 @@ export const browseLocalMediaPlayer = (
|
||||
export const isLocalMediaSourceContentId = (mediaId: string) =>
|
||||
mediaId.startsWith("media-source://media_source");
|
||||
|
||||
export const isImageUploadMediaSourceContentId = (mediaId: string) =>
|
||||
mediaId.startsWith("media-source://image_upload");
|
||||
|
||||
export const uploadLocalMedia = async (
|
||||
hass: HomeAssistant,
|
||||
media_content_id: string,
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
const HAS_CUSTOM_PREVIEW = ["template"];
|
||||
const HAS_CUSTOM_PREVIEW = ["generic_camera", "template"];
|
||||
|
||||
export interface GenericPreview {
|
||||
state: string;
|
||||
|
@@ -40,7 +40,7 @@ export const baseActionStruct = object({
|
||||
enabled: optional(boolean()),
|
||||
});
|
||||
|
||||
const targetStruct = object({
|
||||
export const targetStruct = object({
|
||||
entity_id: optional(union([string(), array(string())])),
|
||||
device_id: optional(union([string(), array(string())])),
|
||||
area_id: optional(union([string(), array(string())])),
|
||||
|
@@ -26,6 +26,7 @@ export type Selector =
|
||||
| AreaFilterSelector
|
||||
| AttributeSelector
|
||||
| BooleanSelector
|
||||
| ButtonToggleSelector
|
||||
| ColorRGBSelector
|
||||
| ColorTempSelector
|
||||
| ConditionSelector
|
||||
@@ -68,7 +69,8 @@ export type Selector =
|
||||
| TTSVoiceSelector
|
||||
| UiActionSelector
|
||||
| UiColorSelector
|
||||
| UiStateContentSelector;
|
||||
| UiStateContentSelector
|
||||
| BackupLocationSelector;
|
||||
|
||||
export interface ActionSelector {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
@@ -107,6 +109,14 @@ export interface BooleanSelector {
|
||||
boolean: {} | null;
|
||||
}
|
||||
|
||||
export interface ButtonToggleSelector {
|
||||
button_toggle: {
|
||||
options: readonly string[] | readonly SelectOption[];
|
||||
translation_key?: string;
|
||||
sort?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ColorRGBSelector {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
color_rgb: {} | null;
|
||||
|
@@ -715,11 +715,13 @@ export const getZwaveNodeRawConfigParameter = (
|
||||
device_id: string,
|
||||
property: number
|
||||
): Promise<number> =>
|
||||
hass.callWS({
|
||||
type: "zwave_js/get_raw_config_parameter",
|
||||
device_id,
|
||||
property,
|
||||
});
|
||||
hass
|
||||
.callWS<{ value: number }>({
|
||||
type: "zwave_js/get_raw_config_parameter",
|
||||
device_id,
|
||||
property,
|
||||
})
|
||||
.then((res) => res.value);
|
||||
|
||||
export const reinterviewZwaveNode = (
|
||||
hass: HomeAssistant,
|
||||
|
@@ -3,7 +3,7 @@ import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-dialog";
|
||||
import { createCloseHeading } from "../../components/ha-dialog";
|
||||
import "../../components/ha-formfield";
|
||||
import "../../components/ha-switch";
|
||||
import type { HaSwitch } from "../../components/ha-switch";
|
||||
@@ -52,14 +52,14 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
<ha-dialog
|
||||
open
|
||||
@closed=${this.closeDialog}
|
||||
.heading=${this.hass.localize(
|
||||
"ui.dialogs.config_entry_system_options.title",
|
||||
{
|
||||
.heading=${createCloseHeading(
|
||||
this.hass,
|
||||
this.hass.localize("ui.dialogs.config_entry_system_options.title", {
|
||||
integration:
|
||||
this.hass.localize(
|
||||
`component.${this._params.entry.domain}.title`
|
||||
) || this._params.entry.domain,
|
||||
}
|
||||
})
|
||||
)}
|
||||
>
|
||||
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { LitElement, html } from "lit";
|
||||
import type { nothing, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { FlowType } from "../../../data/data_entry_flow";
|
||||
import type { GenericPreview } from "../../../data/preview";
|
||||
@@ -11,7 +12,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-alert";
|
||||
|
||||
@customElement("flow-preview-generic")
|
||||
class FlowPreviewGeneric extends LitElement {
|
||||
export class FlowPreviewGeneric extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public flowType!: FlowType;
|
||||
@@ -26,9 +27,9 @@ class FlowPreviewGeneric extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public stepData!: Record<string, any>;
|
||||
|
||||
@state() private _preview?: HassEntity;
|
||||
@state() protected _preview?: HassEntity;
|
||||
|
||||
@state() private _error?: string;
|
||||
@state() protected _error?: string;
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
@@ -46,7 +47,7 @@ class FlowPreviewGeneric extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (this._error) {
|
||||
return html`<ha-alert alert-type="error">${this._error}</ha-alert>`;
|
||||
}
|
||||
|
@@ -0,0 +1,53 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { FlowPreviewGeneric } from "./flow-preview-generic";
|
||||
import "../../../components/ha-hls-player";
|
||||
import "../../../components/ha-circular-progress";
|
||||
|
||||
@customElement("flow-preview-generic_camera")
|
||||
class FlowPreviewGenericCamera extends FlowPreviewGeneric {
|
||||
protected override render() {
|
||||
if (!this._preview) {
|
||||
return nothing;
|
||||
}
|
||||
if (this._error) {
|
||||
return html`<ha-alert alert-type="error">${this._error}</ha-alert>`;
|
||||
}
|
||||
|
||||
const stillUrl = this._preview.attributes.still_url;
|
||||
const streamUrl = this._preview.attributes.stream_url;
|
||||
|
||||
return html` ${stillUrl
|
||||
? html`<p>Still image:</p>
|
||||
<p>
|
||||
<img src=${stillUrl} alt="Still preview" />
|
||||
</p>`
|
||||
: ""}
|
||||
${streamUrl
|
||||
? html`<p>Stream:</p>
|
||||
<ha-circular-progress
|
||||
class="render-spinner"
|
||||
id="hls-load-spinner"
|
||||
indeterminate
|
||||
size="large"
|
||||
></ha-circular-progress>
|
||||
<ha-hls-player
|
||||
autoplay
|
||||
playsinline
|
||||
.hass=${this.hass}
|
||||
.url=${streamUrl}
|
||||
@load=${this._videoLoaded}
|
||||
></ha-hls-player>`
|
||||
: ""}`;
|
||||
}
|
||||
|
||||
private _videoLoaded() {
|
||||
this.shadowRoot!.getElementById("hls-load-spinner")?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"flow-preview-generic_camera": FlowPreviewGenericCamera;
|
||||
}
|
||||
}
|
@@ -50,7 +50,7 @@ export const showConfigFlowDialog = (
|
||||
|
||||
return description
|
||||
? html`
|
||||
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
|
||||
<ha-markdown allow-svg breaks .content=${description}></ha-markdown>
|
||||
`
|
||||
: step.reason;
|
||||
},
|
||||
@@ -71,7 +71,7 @@ export const showConfigFlowDialog = (
|
||||
);
|
||||
return description
|
||||
? html`
|
||||
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
|
||||
<ha-markdown allow-svg breaks .content=${description}></ha-markdown>
|
||||
`
|
||||
: "";
|
||||
},
|
||||
@@ -163,7 +163,7 @@ export const showConfigFlowDialog = (
|
||||
${description
|
||||
? html`
|
||||
<ha-markdown
|
||||
allowsvg
|
||||
allow-svg
|
||||
breaks
|
||||
.content=${description}
|
||||
></ha-markdown>
|
||||
@@ -184,7 +184,7 @@ export const showConfigFlowDialog = (
|
||||
${description
|
||||
? html`
|
||||
<ha-markdown
|
||||
allowsvg
|
||||
allow-svg
|
||||
breaks
|
||||
.content=${description}
|
||||
></ha-markdown>
|
||||
@@ -214,7 +214,7 @@ export const showConfigFlowDialog = (
|
||||
);
|
||||
return description
|
||||
? html`
|
||||
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
|
||||
<ha-markdown allow-svg breaks .content=${description}></ha-markdown>
|
||||
`
|
||||
: "";
|
||||
},
|
||||
@@ -234,7 +234,7 @@ export const showConfigFlowDialog = (
|
||||
);
|
||||
return description
|
||||
? html`
|
||||
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
|
||||
<ha-markdown allow-svg breaks .content=${description}></ha-markdown>
|
||||
`
|
||||
: "";
|
||||
},
|
||||
|
@@ -61,7 +61,7 @@ export const showOptionsFlowDialog = (
|
||||
? html`
|
||||
<ha-markdown
|
||||
breaks
|
||||
allowsvg
|
||||
allow-svg
|
||||
.content=${description}
|
||||
></ha-markdown>
|
||||
`
|
||||
@@ -85,7 +85,7 @@ export const showOptionsFlowDialog = (
|
||||
return description
|
||||
? html`
|
||||
<ha-markdown
|
||||
allowsvg
|
||||
allow-svg
|
||||
breaks
|
||||
.content=${description}
|
||||
></ha-markdown>
|
||||
@@ -183,7 +183,7 @@ export const showOptionsFlowDialog = (
|
||||
return description
|
||||
? html`
|
||||
<ha-markdown
|
||||
allowsvg
|
||||
allow-svg
|
||||
breaks
|
||||
.content=${description}
|
||||
></ha-markdown>
|
||||
@@ -207,7 +207,7 @@ export const showOptionsFlowDialog = (
|
||||
return description
|
||||
? html`
|
||||
<ha-markdown
|
||||
allowsvg
|
||||
allow-svg
|
||||
breaks
|
||||
.content=${description}
|
||||
></ha-markdown>
|
||||
|
@@ -51,7 +51,6 @@ class StepFlowAbort extends LitElement {
|
||||
}
|
||||
|
||||
private async _handleMissingCreds() {
|
||||
this._flowDone();
|
||||
// Prompt to enter credentials and restart integration setup
|
||||
showAddApplicationCredentialDialog(this.params.dialogParentElement!, {
|
||||
selectedDomain: this.domain,
|
||||
@@ -64,6 +63,7 @@ class StepFlowAbort extends LitElement {
|
||||
});
|
||||
},
|
||||
});
|
||||
this._flowDone();
|
||||
}
|
||||
|
||||
private _flowDone(): void {
|
||||
|
@@ -3,7 +3,7 @@ import Cropper from "cropperjs";
|
||||
// @ts-ignore
|
||||
import cropperCss from "cropperjs/dist/cropper.css";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, unsafeCSS } from "lit";
|
||||
import { css, html, nothing, LitElement, unsafeCSS } from "lit";
|
||||
import { customElement, property, state, query } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../components/ha-dialog";
|
||||
@@ -23,6 +23,8 @@ export class HaImagecropperDialog extends LitElement {
|
||||
|
||||
private _cropper?: Cropper;
|
||||
|
||||
@state() private _isTargetAspectRatio?: boolean;
|
||||
|
||||
public showDialog(params: HaImageCropperDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
@@ -33,6 +35,7 @@ export class HaImagecropperDialog extends LitElement {
|
||||
this._params = undefined;
|
||||
this._cropper?.destroy();
|
||||
this._cropper = undefined;
|
||||
this._isTargetAspectRatio = false;
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
@@ -47,6 +50,7 @@ export class HaImagecropperDialog extends LitElement {
|
||||
dragMode: "move",
|
||||
minCropBoxWidth: 50,
|
||||
ready: () => {
|
||||
this._isTargetAspectRatio = this._checkMatchAspectRatio();
|
||||
URL.revokeObjectURL(this._image!.src);
|
||||
},
|
||||
});
|
||||
@@ -55,6 +59,25 @@ export class HaImagecropperDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _checkMatchAspectRatio(): boolean {
|
||||
const targetRatio = this._params?.options.aspectRatio;
|
||||
if (!targetRatio) {
|
||||
return true;
|
||||
}
|
||||
const imageData = this._cropper!.getImageData();
|
||||
if (imageData.aspectRatio === targetRatio) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the image is not exactly the aspect ratio see if it is within a pixel.
|
||||
if (imageData.naturalWidth > imageData.naturalHeight) {
|
||||
const targetHeight = imageData.naturalWidth / targetRatio;
|
||||
return Math.abs(targetHeight - imageData.naturalHeight) <= 1;
|
||||
}
|
||||
const targetWidth = imageData.naturalHeight * targetRatio;
|
||||
return Math.abs(targetWidth - imageData.naturalWidth) <= 1;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<ha-dialog
|
||||
@closed=${this.closeDialog}
|
||||
@@ -72,6 +95,12 @@ export class HaImagecropperDialog extends LitElement {
|
||||
<mwc-button slot="secondaryAction" @click=${this.closeDialog}>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</mwc-button>
|
||||
${this._isTargetAspectRatio
|
||||
? html`<mwc-button slot="primaryAction" @click=${this._useOriginal}>
|
||||
${this.hass.localize("ui.dialogs.image_cropper.use_original")}
|
||||
</mwc-button>`
|
||||
: nothing}
|
||||
|
||||
<mwc-button slot="primaryAction" @click=${this._cropImage}>
|
||||
${this.hass.localize("ui.dialogs.image_cropper.crop")}
|
||||
</mwc-button>
|
||||
@@ -95,6 +124,11 @@ export class HaImagecropperDialog extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _useOriginal() {
|
||||
this._params!.croppedCallback(this._params!.file);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
|
@@ -87,15 +87,23 @@ export const showDialog = async (
|
||||
};
|
||||
}
|
||||
|
||||
// Get the focus targets after the dialog closes
|
||||
LOADED[dialogTag].closedFocusTargets = ancestorsWithProperty(
|
||||
deepActiveElement(),
|
||||
FOCUS_TARGET
|
||||
);
|
||||
|
||||
const { state } = mainWindow.history;
|
||||
// if the same dialog is already open, don't push state
|
||||
if (addHistory) {
|
||||
const { history } = mainWindow;
|
||||
if (history.state?.dialog && !OPEN_DIALOG_STACK.length) {
|
||||
// theres is a dialog state in history, but no dialogs open
|
||||
// wait for history.back() to update the state
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve);
|
||||
});
|
||||
return showDialog(
|
||||
element,
|
||||
root,
|
||||
dialogTag,
|
||||
dialogParams,
|
||||
dialogImport,
|
||||
addHistory
|
||||
);
|
||||
}
|
||||
OPEN_DIALOG_STACK.push({
|
||||
element,
|
||||
root,
|
||||
@@ -105,16 +113,22 @@ export const showDialog = async (
|
||||
addHistory,
|
||||
});
|
||||
const newState = { dialog: dialogTag };
|
||||
if (state?.dialog) {
|
||||
// if the dialog is already open, replace the name
|
||||
mainWindow.history.replaceState(newState, "");
|
||||
if (history.state?.dialog) {
|
||||
// if a dialog is already open, replace the name
|
||||
history.replaceState(newState, "");
|
||||
} else {
|
||||
// if the dialog is not open, push a new state so back() will close the dialog
|
||||
mainWindow.history.replaceState({ ...state, opensDialog: true }, "");
|
||||
mainWindow.history.pushState(newState, "");
|
||||
// if a dialog is not open, push a new state so back() will close the dialog
|
||||
history.replaceState({ ...history.state, opensDialog: true }, "");
|
||||
history.pushState(newState, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Get the focus targets after the dialog closes
|
||||
LOADED[dialogTag].closedFocusTargets = ancestorsWithProperty(
|
||||
deepActiveElement(),
|
||||
FOCUS_TARGET
|
||||
);
|
||||
|
||||
const dialogElement = await LOADED[dialogTag].element;
|
||||
|
||||
// Append it again so it's the last element in the root,
|
||||
@@ -125,25 +139,6 @@ export const showDialog = async (
|
||||
return true;
|
||||
};
|
||||
|
||||
export const showDialogFromHistory = async (dialogTag: string) => {
|
||||
const dialogState = OPEN_DIALOG_STACK.find(
|
||||
(state) => state.dialogTag === dialogTag
|
||||
);
|
||||
if (dialogState) {
|
||||
showDialog(
|
||||
dialogState.element,
|
||||
dialogState.root,
|
||||
dialogTag,
|
||||
dialogState.dialogParams,
|
||||
dialogState.dialogImport,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
// remove the dialog from history if already closed
|
||||
mainWindow.history.back();
|
||||
}
|
||||
};
|
||||
|
||||
export const closeDialog = async (dialogTag: string): Promise<boolean> => {
|
||||
if (!(dialogTag in LOADED)) {
|
||||
return true;
|
||||
@@ -171,10 +166,23 @@ export const closeLastDialog = async () => {
|
||||
""
|
||||
);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const _handleClosed = async (ev: HASSDomEvent<DialogClosedParams>) => {
|
||||
export const closeAllDialogs = async () => {
|
||||
for (let i = OPEN_DIALOG_STACK.length - 1; i >= 0; i--) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const closed = await closeDialog(OPEN_DIALOG_STACK[i].dialogTag);
|
||||
if (!closed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const _handleClosed = (ev: HASSDomEvent<DialogClosedParams>) => {
|
||||
// If not closed by navigating back, remove the open state from history
|
||||
const dialogIndex = OPEN_DIALOG_STACK.findIndex(
|
||||
(state) => state.dialogTag === ev.detail.dialog
|
||||
@@ -189,7 +197,8 @@ const _handleClosed = async (ev: HASSDomEvent<DialogClosedParams>) => {
|
||||
{ dialog: OPEN_DIALOG_STACK[OPEN_DIALOG_STACK.length - 1].dialogTag },
|
||||
""
|
||||
);
|
||||
} else {
|
||||
} else if (dialogIndex !== -1) {
|
||||
// if the dialog is the last one and it was indeed open, go back
|
||||
mainWindow.history.back();
|
||||
}
|
||||
}
|
||||
@@ -216,6 +225,7 @@ export const makeDialogManager = (
|
||||
};
|
||||
|
||||
const _handleClosedFocus = async (ev: HASSDomEvent<DialogClosedParams>) => {
|
||||
if (!LOADED[ev.detail.dialog]) return;
|
||||
const closedFocusTargets = LOADED[ev.detail.dialog].closedFocusTargets;
|
||||
delete LOADED[ev.detail.dialog].closedFocusTargets;
|
||||
if (!closedFocusTargets) return;
|
||||
|
@@ -13,7 +13,6 @@ import {
|
||||
} from "../../../../common/color/convert-color";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { throttle } from "../../../../common/util/throttle";
|
||||
import "../../../../components/ha-button-toggle-group";
|
||||
import "../../../../components/ha-hs-color-picker";
|
||||
import "../../../../components/ha-icon";
|
||||
import "../../../../components/ha-icon-button-prev";
|
||||
|
@@ -213,9 +213,10 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
|
||||
ha-icon-button[action="turn_off"],
|
||||
ha-icon-button[action="turn_on"] {
|
||||
margin-inline-end: auto;
|
||||
margin-right: auto;
|
||||
margin-left: inherit;
|
||||
margin-inline-start: inherit;
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.controls {
|
||||
|
@@ -99,6 +99,7 @@ class MoreInfoScript extends LitElement {
|
||||
${this.hass.localize("ui.card.script.run_script")}
|
||||
</div>
|
||||
<ha-service-control
|
||||
hide-picker
|
||||
hide-description
|
||||
.hass=${this.hass}
|
||||
.value=${this._scriptData}
|
||||
|
@@ -12,8 +12,6 @@ import "../../../components/ha-faded";
|
||||
import "../../../components/ha-formfield";
|
||||
import "../../../components/ha-markdown";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-switch";
|
||||
import type { HaSwitch } from "../../../components/ha-switch";
|
||||
import { isUnavailableState } from "../../../data/entity";
|
||||
import type { UpdateEntity } from "../../../data/update";
|
||||
import {
|
||||
@@ -136,22 +134,6 @@ class MoreInfoUpdate extends LitElement {
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="footer">
|
||||
${supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP)
|
||||
? html`
|
||||
<ha-settings-row>
|
||||
<span slot="heading">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.update.create_backup"
|
||||
)}
|
||||
</span>
|
||||
<ha-switch
|
||||
id="create-backup"
|
||||
checked
|
||||
.disabled=${updateIsInstalling(this.stateObj)}
|
||||
></ha-switch>
|
||||
</ha-settings-row>
|
||||
`
|
||||
: nothing}
|
||||
<div class="actions">
|
||||
${this.stateObj.state === BINARY_STATE_OFF &&
|
||||
this.stateObj.attributes.skipped_version
|
||||
@@ -224,28 +206,11 @@ class MoreInfoUpdate extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
get _shouldCreateBackup(): boolean | null {
|
||||
if (!supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
|
||||
return null;
|
||||
}
|
||||
const createBackupSwitch = this.shadowRoot?.getElementById(
|
||||
"create-backup"
|
||||
) as HaSwitch;
|
||||
if (createBackupSwitch) {
|
||||
return createBackupSwitch.checked;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleInstall(): void {
|
||||
const installData: Record<string, any> = {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
};
|
||||
|
||||
if (this._shouldCreateBackup) {
|
||||
installData.backup = true;
|
||||
}
|
||||
|
||||
if (
|
||||
supportsFeature(this.stateObj!, UpdateEntityFeature.SPECIFIC_VERSION) &&
|
||||
this.stateObj!.attributes.latest_version
|
||||
|
@@ -338,7 +338,7 @@ export class MoreInfoDialog extends LitElement {
|
||||
></ha-icon-button>
|
||||
<ha-button-menu
|
||||
corner="BOTTOM_END"
|
||||
menuCorner="END"
|
||||
menu-corner="END"
|
||||
slot="actionItems"
|
||||
@closed=${stopPropagation}
|
||||
fixed
|
||||
@@ -426,7 +426,7 @@ export class MoreInfoDialog extends LitElement {
|
||||
? html`
|
||||
<ha-button-menu
|
||||
corner="BOTTOM_END"
|
||||
menuCorner="END"
|
||||
menu-corner="END"
|
||||
slot="actionItems"
|
||||
@closed=${stopPropagation}
|
||||
fixed
|
||||
@@ -545,11 +545,7 @@ export class MoreInfoDialog extends LitElement {
|
||||
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
|
||||
--vertical-align-dialog: flex-start;
|
||||
--dialog-surface-margin-top: 40px;
|
||||
/* This is needed for the tooltip of the history charts to be positioned correctly */
|
||||
--dialog-surface-position: static;
|
||||
--dialog-content-position: static;
|
||||
--dialog-content-padding: 0;
|
||||
--chart-base-position: static;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
@@ -3,7 +3,6 @@ import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { createSearchParam } from "../../common/url/search-params";
|
||||
import type { ChartResizeOptions } from "../../components/chart/ha-chart-base";
|
||||
@@ -77,7 +76,7 @@ export class MoreInfoHistory extends LitElement {
|
||||
</div>
|
||||
${__DEMO__
|
||||
? nothing
|
||||
: html`<a href=${this._showMoreHref} @click=${this._close}
|
||||
: html`<a href=${this._showMoreHref}
|
||||
>${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.show_more"
|
||||
)}</a
|
||||
@@ -244,10 +243,6 @@ export class MoreInfoHistory extends LitElement {
|
||||
this._setRedrawTimer();
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
setTimeout(() => fireEvent(this, "close-dialog"), 500);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.header {
|
||||
display: flex;
|
||||
|
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { createSearchParam } from "../../common/url/search-params";
|
||||
import "../../panels/logbook/ha-logbook";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -36,7 +35,7 @@ export class MoreInfoLogbook extends LitElement {
|
||||
<div class="title">
|
||||
${this.hass.localize("ui.dialogs.more_info_control.logbook")}
|
||||
</div>
|
||||
<a href=${this._showMoreHref} @click=${this._close}
|
||||
<a href=${this._showMoreHref}
|
||||
>${this.hass.localize("ui.dialogs.more_info_control.show_more")}</a
|
||||
>
|
||||
</div>
|
||||
@@ -67,10 +66,6 @@ export class MoreInfoLogbook extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
setTimeout(() => fireEvent(this, "close-dialog"), 500);
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
css`
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user