mirror of
https://github.com/home-assistant/frontend.git
synced 2025-09-04 19:05:22 +00:00
Compare commits
263 Commits
reduce-mot
...
20250205.0
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9d7d332790 | ||
![]() |
f1173dd84b | ||
![]() |
44dcca9923 | ||
![]() |
bd74d39dd8 | ||
![]() |
172d6c3079 | ||
![]() |
56539e8065 | ||
![]() |
8f6867f142 | ||
![]() |
d51f8995dd | ||
![]() |
f2e35dc70a | ||
![]() |
6487b9b7ea | ||
![]() |
e50b658db7 | ||
![]() |
6efe237639 | ||
![]() |
4a94cfc05b | ||
![]() |
7cbdb1dcfd | ||
![]() |
553bb61db7 | ||
![]() |
786ff787d1 | ||
![]() |
e5fea98460 | ||
![]() |
31180e3a9e | ||
![]() |
ce0f02a45b | ||
![]() |
53f090356e | ||
![]() |
776c4da688 | ||
![]() |
849922f7be | ||
![]() |
a26701808f | ||
![]() |
ef3bea71a0 | ||
![]() |
fcf655b0ec | ||
![]() |
b263b74916 | ||
![]() |
0f4b6b423a | ||
![]() |
72df585c5e | ||
![]() |
4698a63642 | ||
![]() |
6eb43a7d61 | ||
![]() |
af35b15400 | ||
![]() |
0d50d2664f | ||
![]() |
ff1159402e | ||
![]() |
f8742ae690 | ||
![]() |
c786d26542 | ||
![]() |
3f8ff94002 | ||
![]() |
1de740e7b5 | ||
![]() |
5abfb90b16 | ||
![]() |
6b691063a8 | ||
![]() |
d1d746e7e6 | ||
![]() |
2fcb64d4a1 | ||
![]() |
3769f8c7c0 | ||
![]() |
f0a56e75f5 | ||
![]() |
15f33e1f19 | ||
![]() |
181122177b | ||
![]() |
684cd0f627 | ||
![]() |
277202e363 | ||
![]() |
b388d1fd42 | ||
![]() |
251e6399f5 | ||
![]() |
f44c5d7a63 | ||
![]() |
cae1ca52f0 | ||
![]() |
9449f5ad0a | ||
![]() |
c337bc5f97 | ||
![]() |
6aab60cf45 | ||
![]() |
52e9bc3213 | ||
![]() |
e48b2383cf | ||
![]() |
002a249777 | ||
![]() |
10498ce18d | ||
![]() |
6a5936b2b2 | ||
![]() |
dc68aaa803 | ||
![]() |
e7931ce049 | ||
![]() |
59b2582fe3 | ||
![]() |
8577b0721c | ||
![]() |
91319be855 | ||
![]() |
0dff538298 | ||
![]() |
b95e87845f | ||
![]() |
bc49ebc489 | ||
![]() |
c97d0ce68a | ||
![]() |
a0e7d6f1c6 | ||
![]() |
25d2a5ddac | ||
![]() |
a2f2d64f5c | ||
![]() |
fcdcbbda05 | ||
![]() |
4b5c7fc2de | ||
![]() |
dd64d88afe | ||
![]() |
5feffd08ff | ||
![]() |
543c7df3e0 | ||
![]() |
167f859f2a | ||
![]() |
c7d9699a24 | ||
![]() |
a638cf443d | ||
![]() |
438d1c13ef | ||
![]() |
cc8869b9f9 | ||
![]() |
cbdb7406ad | ||
![]() |
f8d2560104 | ||
![]() |
cc48ae82d6 | ||
![]() |
e1bda9b57d | ||
![]() |
89e9316a40 | ||
![]() |
85e4975206 | ||
![]() |
142faba04d | ||
![]() |
c1c6d71ccf | ||
![]() |
5204a565cf | ||
![]() |
05b8a48ba8 | ||
![]() |
41770a89b4 | ||
![]() |
b7d14d7950 | ||
![]() |
4ccef6f28b | ||
![]() |
d1be441455 | ||
![]() |
b3391b34e4 | ||
![]() |
9359e9d475 | ||
![]() |
5453da75ea | ||
![]() |
bc21877008 | ||
![]() |
c9956c65e9 | ||
![]() |
74814cc305 | ||
![]() |
8f231d7b3e | ||
![]() |
5b6818d72d | ||
![]() |
8c75865a02 | ||
![]() |
99d832ac77 | ||
![]() |
240e48f5c1 | ||
![]() |
77fc11cda6 | ||
![]() |
f8f152f118 | ||
![]() |
20a67cf73c | ||
![]() |
dd11b3092e | ||
![]() |
7f3363621e | ||
![]() |
edce1901d7 | ||
![]() |
546087066a | ||
![]() |
a95c589a06 | ||
![]() |
5f6da9d959 | ||
![]() |
bc94ca0a0a | ||
![]() |
dc8d483e8b | ||
![]() |
a329553ce1 | ||
![]() |
1f8cfdd0de | ||
![]() |
fd1f966216 | ||
![]() |
243a2ce0e2 | ||
![]() |
77c1786171 | ||
![]() |
7a7c204d74 | ||
![]() |
dcb74ad2ee | ||
![]() |
fd4c62a852 | ||
![]() |
bf1b0ac949 | ||
![]() |
c6f0c62fd6 | ||
![]() |
ac98672cb7 | ||
![]() |
6b471ba6e7 | ||
![]() |
b28e7d2f06 | ||
![]() |
bf471eb8c3 | ||
![]() |
8c52bc3ffb | ||
![]() |
54b328648a | ||
![]() |
62a9da2de2 | ||
![]() |
25ff96b9bf | ||
![]() |
caeb616dc6 | ||
![]() |
fb79e2cfb2 | ||
![]() |
666316e44a | ||
![]() |
1532093426 | ||
![]() |
2effb0935c | ||
![]() |
06a08bb4f2 | ||
![]() |
09102d34d6 | ||
![]() |
27d683f6e8 | ||
![]() |
b89bd0be3b | ||
![]() |
51e6e6d230 | ||
![]() |
9005f8faa9 | ||
![]() |
7aa2abc9c2 | ||
![]() |
898fd51c7d | ||
![]() |
b3141a0653 | ||
![]() |
1025f73c36 | ||
![]() |
1cd44728df | ||
![]() |
eaab19fb8c | ||
![]() |
c4b2896fac | ||
![]() |
ca2a9f9171 | ||
![]() |
9e868e144d | ||
![]() |
fcb6da55d8 | ||
![]() |
87907b98bd | ||
![]() |
7535d66373 | ||
![]() |
e994e3565d | ||
![]() |
562589a6cb | ||
![]() |
e87f6d6d5e | ||
![]() |
3feedb792a | ||
![]() |
fc290028c6 | ||
![]() |
2f5fd6f0c7 | ||
![]() |
9a3ca59d77 | ||
![]() |
f76c22cb5b | ||
![]() |
aad94624e2 | ||
![]() |
076ab91199 | ||
![]() |
d121b33263 | ||
![]() |
dbfcf310c3 | ||
![]() |
25ea6c2c55 | ||
![]() |
b2b2c21477 | ||
![]() |
09777db549 | ||
![]() |
f108f65e39 | ||
![]() |
2ee9824bcc | ||
![]() |
812461ea00 | ||
![]() |
c430c28c2a | ||
![]() |
d67c463b98 | ||
![]() |
3ffbd435e0 | ||
![]() |
2e2f39adbd | ||
![]() |
5e7f356707 | ||
![]() |
b02d0e58b1 | ||
![]() |
c01d3aee41 | ||
![]() |
6dde7d6945 | ||
![]() |
14c71f436e | ||
![]() |
9acdd9f903 | ||
![]() |
12b2edaa65 | ||
![]() |
d55d388046 | ||
![]() |
ec1dedcb6b | ||
![]() |
d0fbba5063 | ||
![]() |
344e083ac6 | ||
![]() |
0ee6548650 | ||
![]() |
9ff1ce3c96 | ||
![]() |
f53ac94e76 | ||
![]() |
6d084813d5 | ||
![]() |
926f5e3cd8 | ||
![]() |
befc650f81 | ||
![]() |
2019b8992e | ||
![]() |
4d2e9f203f | ||
![]() |
760d898de7 | ||
![]() |
a755af96a6 | ||
![]() |
046b90ae25 | ||
![]() |
a3e8bcf848 | ||
![]() |
4b13dde92e | ||
![]() |
7aa2136c21 | ||
![]() |
47308e7b46 | ||
![]() |
ec1fc09140 | ||
![]() |
e2ad94469a | ||
![]() |
70541ec966 | ||
![]() |
0bf64ee7f4 | ||
![]() |
4ea0c83fbe | ||
![]() |
d126e02747 | ||
![]() |
102a9eeb61 | ||
![]() |
fc3e99e794 | ||
![]() |
70953788cc | ||
![]() |
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 | ||
![]() |
c05054e4ff | ||
![]() |
f416b1b5da | ||
![]() |
e7d9032cc4 | ||
![]() |
331385794c | ||
![]() |
a7cacbbbe6 |
4
.github/workflows/cast_deployment.yaml
vendored
4
.github/workflows/cast_deployment.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
ref: dev
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
ref: master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
4
.github/workflows/demo_deployment.yaml
vendored
4
.github/workflows/demo_deployment.yaml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
ref: dev
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
ref: master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
2
.github/workflows/design_deployment.yaml
vendored
2
.github/workflows/design_deployment.yaml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
2
.github/workflows/design_preview.yaml
vendored
2
.github/workflows/design_preview.yaml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
2
.github/workflows/nightly.yaml
vendored
2
.github/workflows/nightly.yaml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
2
.github/workflows/release-drafter.yaml
vendored
2
.github/workflows/release-drafter.yaml
vendored
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@v6.0.0
|
||||
- uses: release-drafter/release-drafter@v6.1.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
6
.github/workflows/release.yaml
vendored
6
.github/workflows/release.yaml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: home-assistant/actions/helpers/verify-version@master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4.1.0
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@v9.0.0
|
||||
uses: actions/stale@v9.1.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
@@ -124,6 +124,8 @@ const AREAS: AreaRegistryEntry[] = [
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
@@ -135,6 +137,8 @@ const AREAS: AreaRegistryEntry[] = [
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
@@ -146,6 +150,8 @@ const AREAS: AreaRegistryEntry[] = [
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
|
@@ -123,6 +123,8 @@ const AREAS: AreaRegistryEntry[] = [
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
@@ -134,6 +136,8 @@ const AREAS: AreaRegistryEntry[] = [
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
@@ -145,6 +149,8 @@ const AREAS: AreaRegistryEntry[] = [
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
|
@@ -9,6 +9,7 @@ import {
|
||||
} from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { atLeastVersion } from "../../../src/common/config/version";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import "../../../src/components/buttons/ha-progress-button";
|
||||
import "../../../src/components/ha-alert";
|
||||
@@ -18,7 +19,11 @@ import "../../../src/components/ha-checkbox";
|
||||
import "../../../src/components/ha-faded";
|
||||
import "../../../src/components/ha-icon-button";
|
||||
import "../../../src/components/ha-markdown";
|
||||
import "../../../src/components/ha-md-list";
|
||||
import "../../../src/components/ha-md-list-item";
|
||||
import "../../../src/components/ha-svg-icon";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HaSwitch } from "../../../src/components/ha-switch";
|
||||
import type { HassioAddonDetails } from "../../../src/data/hassio/addon";
|
||||
import {
|
||||
fetchHassioAddonChangelog,
|
||||
@@ -121,6 +126,8 @@ class UpdateAvailableCard extends LitElement {
|
||||
|
||||
const changelog = changelogUrl(this._updateType, this._version_latest);
|
||||
|
||||
const createBackupTexts = this._computeCreateBackupTexts();
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
@@ -160,6 +167,30 @@ class UpdateAvailableCard extends LitElement {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
${createBackupTexts
|
||||
? html`
|
||||
<hr />
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${createBackupTexts.title}
|
||||
</span>
|
||||
|
||||
${createBackupTexts.description
|
||||
? html`
|
||||
<span slot="supporting-text">
|
||||
${createBackupTexts.description}
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
<ha-switch
|
||||
slot="end"
|
||||
id="create-backup"
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
`
|
||||
: nothing}
|
||||
`
|
||||
: html`<ha-circular-progress
|
||||
aria-label="Updating"
|
||||
@@ -227,6 +258,48 @@ class UpdateAvailableCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _computeCreateBackupTexts():
|
||||
| { title: string; description?: string }
|
||||
| undefined {
|
||||
// Addon backup
|
||||
if (
|
||||
this._updateType === "addon" &&
|
||||
atLeastVersion(this.hass.config.version, 2025, 2, 0)
|
||||
) {
|
||||
const version = this._version;
|
||||
return {
|
||||
title: this.supervisor.localize("update_available.create_backup.addon"),
|
||||
description: this.supervisor.localize(
|
||||
"update_available.create_backup.addon_description",
|
||||
{ version: version }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Old behavior
|
||||
if (this._updateType && ["core", "addon"].includes(this._updateType)) {
|
||||
return {
|
||||
title: this.supervisor.localize(
|
||||
"update_available.create_backup.generic"
|
||||
),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get _shouldCreateBackup(): boolean {
|
||||
if (this._updateType && !["core", "addon"].includes(this._updateType)) {
|
||||
return false;
|
||||
}
|
||||
const createBackupSwitch = this.shadowRoot?.getElementById(
|
||||
"create-backup"
|
||||
) as HaSwitch;
|
||||
if (createBackupSwitch) {
|
||||
return createBackupSwitch.checked;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
get _version(): string {
|
||||
return this._updateType
|
||||
? this._updateType === "addon"
|
||||
@@ -341,14 +414,22 @@ class UpdateAvailableCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _update() {
|
||||
if (this._shouldCreateBackup && this.supervisor.info.state === "freeze") {
|
||||
this._error = this.supervisor.localize("backup.backup_already_running");
|
||||
return;
|
||||
}
|
||||
this._error = undefined;
|
||||
this._updating = true;
|
||||
|
||||
try {
|
||||
if (this._updateType === "addon") {
|
||||
await updateHassioAddon(this.hass, this.addonSlug!);
|
||||
await updateHassioAddon(
|
||||
this.hass,
|
||||
this.addonSlug!,
|
||||
this._shouldCreateBackup
|
||||
);
|
||||
} else if (this._updateType === "core") {
|
||||
await updateCore(this.hass);
|
||||
await updateCore(this.hass, this._shouldCreateBackup);
|
||||
} else if (this._updateType === "os") {
|
||||
await updateOS(this.hass);
|
||||
} else if (this._updateType === "supervisor") {
|
||||
@@ -403,6 +484,17 @@ class UpdateAvailableCard extends LitElement {
|
||||
border-bottom: none;
|
||||
margin: 16px 0 0 0;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
padding: 0;
|
||||
margin-bottom: -16px;
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
--md-list-item-leading-space: 0;
|
||||
--md-list-item-trailing-space: 0;
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
40
package.json
40
package.json
@@ -26,7 +26,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.26.0",
|
||||
"@babel/runtime": "7.26.7",
|
||||
"@braintree/sanitize-url": "7.1.1",
|
||||
"@codemirror/autocomplete": "6.18.4",
|
||||
"@codemirror/commands": "6.8.0",
|
||||
@@ -99,8 +99,6 @@
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"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.40.0",
|
||||
@@ -110,14 +108,15 @@
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"dialog-polyfill": "0.5.6",
|
||||
"element-internals-polyfill": "1.3.12",
|
||||
"echarts": "5.6.0",
|
||||
"element-internals-polyfill": "1.3.13",
|
||||
"fuse.js": "7.0.0",
|
||||
"google-timezones-json": "1.2.0",
|
||||
"gulp-zopfli-green": "6.0.2",
|
||||
"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.11",
|
||||
"intl-messageformat": "10.7.14",
|
||||
"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",
|
||||
@@ -126,7 +125,7 @@
|
||||
"luxon": "3.5.0",
|
||||
"marked": "15.0.6",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.1",
|
||||
"node-vibrant": "4.0.3",
|
||||
"punycode": "2.3.1",
|
||||
"qr-scanner": "1.4.2",
|
||||
"qrcode": "1.5.4",
|
||||
@@ -153,20 +152,20 @@
|
||||
"xss": "1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.26.0",
|
||||
"@babel/core": "7.26.7",
|
||||
"@babel/helper-define-polyfill-provider": "0.6.3",
|
||||
"@babel/plugin-proposal-decorators": "7.25.9",
|
||||
"@babel/plugin-transform-runtime": "7.25.9",
|
||||
"@babel/preset-env": "7.26.0",
|
||||
"@babel/preset-env": "7.26.7",
|
||||
"@babel/preset-typescript": "7.26.0",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.17.0",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.18.2",
|
||||
"@lokalise/node-api": "13.0.0",
|
||||
"@octokit/auth-oauth-device": "7.1.2",
|
||||
"@octokit/plugin-retry": "7.1.3",
|
||||
"@octokit/rest": "21.1.0",
|
||||
"@rsdoctor/rspack-plugin": "0.4.13",
|
||||
"@rspack/cli": "1.1.8",
|
||||
"@rspack/core": "1.1.8",
|
||||
"@rspack/cli": "1.2.2",
|
||||
"@rspack/core": "1.2.2",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.20",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -184,16 +183,16 @@
|
||||
"@types/tar": "6.1.13",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@typescript-eslint/eslint-plugin": "8.20.0",
|
||||
"@typescript-eslint/parser": "8.20.0",
|
||||
"@vitest/coverage-v8": "2.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.21.0",
|
||||
"@typescript-eslint/parser": "8.21.0",
|
||||
"@vitest/coverage-v8": "3.0.4",
|
||||
"babel-loader": "9.2.1",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.3",
|
||||
"del": "8.0.0",
|
||||
"eslint": "9.18.0",
|
||||
"eslint": "9.19.0",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-config-prettier": "10.0.1",
|
||||
"eslint-import-resolver-webpack": "0.13.10",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-lit": "1.15.0",
|
||||
@@ -201,7 +200,7 @@
|
||||
"eslint-plugin-unused-imports": "4.1.4",
|
||||
"eslint-plugin-wc": "2.2.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.2.0",
|
||||
"fs-extra": "11.3.0",
|
||||
"glob": "11.0.1",
|
||||
"gulp": "5.0.0",
|
||||
"gulp-brotli": "3.0.0",
|
||||
@@ -211,7 +210,7 @@
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "26.0.0",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "15.3.0",
|
||||
"lint-staged": "15.4.3",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.5.0",
|
||||
@@ -225,7 +224,7 @@
|
||||
"terser-webpack-plugin": "5.3.11",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "5.7.3",
|
||||
"vitest": "2.1.8",
|
||||
"vitest": "3.0.4",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
|
||||
@@ -239,7 +238,8 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "1.6.3",
|
||||
"@fullcalendar/daygrid": "6.1.15",
|
||||
"globals": "15.14.0"
|
||||
"globals": "15.14.0",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"packageManager": "yarn@4.6.0"
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20241224.0"
|
||||
version = "20250205.0"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "The Home Assistant frontend"
|
||||
readme = "README.md"
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import memoizeOne from "memoize-one";
|
||||
import { theme2hex } from "./convert-color";
|
||||
|
||||
export const COLORS = [
|
||||
@@ -74,3 +75,12 @@ export function getGraphColorByIndex(
|
||||
getColorByIndex(index);
|
||||
return theme2hex(themeColor);
|
||||
}
|
||||
|
||||
export const getAllGraphColors = memoizeOne(
|
||||
(style: CSSStyleDeclaration) =>
|
||||
COLORS.map((_color, index) => getGraphColorByIndex(index, style)),
|
||||
(newArgs: [CSSStyleDeclaration], lastArgs: [CSSStyleDeclaration]) =>
|
||||
// this is not ideal, but we need to memoize the colors
|
||||
newArgs[0].getPropertyValue("--graph-color-1") ===
|
||||
lastArgs[0].getPropertyValue("--graph-color-1")
|
||||
);
|
||||
|
@@ -65,6 +65,18 @@ const formatShortDateTimeMem = memoizeOne(
|
||||
})
|
||||
);
|
||||
|
||||
export const formatShortDateTimeWithConditionalYear = (
|
||||
dateObj: Date,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig
|
||||
) => {
|
||||
const now = new Date();
|
||||
if (now.getFullYear() === dateObj.getFullYear()) {
|
||||
return formatShortDateTime(dateObj, locale, config);
|
||||
}
|
||||
return formatShortDateTimeWithYear(dateObj, locale, config);
|
||||
};
|
||||
|
||||
// August 9, 2021, 8:23:15 AM
|
||||
export const formatDateTimeWithSeconds = (
|
||||
dateObj: Date,
|
||||
|
51
src/components/chart/axis-label.ts
Normal file
51
src/components/chart/axis-label.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import {
|
||||
formatDateMonth,
|
||||
formatDateMonthYear,
|
||||
formatDateVeryShort,
|
||||
formatDateWeekdayShort,
|
||||
} from "../../common/datetime/format_date";
|
||||
import {
|
||||
formatTime,
|
||||
formatTimeWithSeconds,
|
||||
} from "../../common/datetime/format_time";
|
||||
|
||||
export function formatTimeLabel(
|
||||
value: number | Date,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig,
|
||||
minutesDifference: number
|
||||
) {
|
||||
const dayDifference = minutesDifference / 60 / 24;
|
||||
const date = new Date(value);
|
||||
if (dayDifference > 88) {
|
||||
return date.getMonth() === 0
|
||||
? `{bold|${formatDateMonthYear(date, locale, config)}}`
|
||||
: formatDateMonth(date, locale, config);
|
||||
}
|
||||
if (dayDifference > 35) {
|
||||
return date.getDate() === 1
|
||||
? `{bold|${formatDateVeryShort(date, locale, config)}}`
|
||||
: formatDateVeryShort(date, locale, config);
|
||||
}
|
||||
if (dayDifference > 7) {
|
||||
const label = formatDateVeryShort(date, locale, config);
|
||||
return date.getDate() === 1 ? `{bold|${label}}` : label;
|
||||
}
|
||||
if (dayDifference > 2) {
|
||||
return formatDateWeekdayShort(date, locale, config);
|
||||
}
|
||||
if (minutesDifference && minutesDifference < 5) {
|
||||
return formatTimeWithSeconds(date, locale, config);
|
||||
}
|
||||
if (
|
||||
date.getHours() === 0 &&
|
||||
date.getMinutes() === 0 &&
|
||||
date.getSeconds() === 0
|
||||
) {
|
||||
// show only date for the beginning of the day
|
||||
return `{bold|${formatDateVeryShort(date, locale, config)}}`;
|
||||
}
|
||||
return formatTime(date, locale, config);
|
||||
}
|
@@ -1,269 +0,0 @@
|
||||
import { _adapters } from "chart.js";
|
||||
import {
|
||||
startOfSecond,
|
||||
startOfMinute,
|
||||
startOfHour,
|
||||
startOfDay,
|
||||
startOfWeek,
|
||||
startOfMonth,
|
||||
startOfQuarter,
|
||||
startOfYear,
|
||||
addMilliseconds,
|
||||
addSeconds,
|
||||
addMinutes,
|
||||
addHours,
|
||||
addDays,
|
||||
addWeeks,
|
||||
addMonths,
|
||||
addQuarters,
|
||||
addYears,
|
||||
differenceInMilliseconds,
|
||||
differenceInSeconds,
|
||||
differenceInMinutes,
|
||||
differenceInHours,
|
||||
differenceInDays,
|
||||
differenceInWeeks,
|
||||
differenceInMonths,
|
||||
differenceInQuarters,
|
||||
differenceInYears,
|
||||
endOfSecond,
|
||||
endOfMinute,
|
||||
endOfHour,
|
||||
endOfDay,
|
||||
endOfWeek,
|
||||
endOfMonth,
|
||||
endOfQuarter,
|
||||
endOfYear,
|
||||
} from "date-fns";
|
||||
import {
|
||||
formatDate,
|
||||
formatDateMonth,
|
||||
formatDateMonthYear,
|
||||
formatDateVeryShort,
|
||||
formatDateWeekdayDay,
|
||||
formatDateYear,
|
||||
} from "../../common/datetime/format_date";
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDateTimeWithSeconds,
|
||||
} from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
formatTime,
|
||||
formatTimeWithSeconds,
|
||||
} from "../../common/datetime/format_time";
|
||||
|
||||
const FORMATS = {
|
||||
datetime: "datetime",
|
||||
datetimeseconds: "datetimeseconds",
|
||||
millisecond: "millisecond",
|
||||
second: "second",
|
||||
minute: "minute",
|
||||
hour: "hour",
|
||||
day: "day",
|
||||
date: "date",
|
||||
weekday: "weekday",
|
||||
week: "week",
|
||||
month: "month",
|
||||
monthyear: "monthyear",
|
||||
quarter: "quarter",
|
||||
year: "year",
|
||||
};
|
||||
|
||||
_adapters._date.override({
|
||||
formats: () => FORMATS,
|
||||
parse: (value: Date | number) => {
|
||||
if (!(value instanceof Date)) {
|
||||
return value;
|
||||
}
|
||||
return value.getTime();
|
||||
},
|
||||
format: function (time, fmt: keyof typeof FORMATS) {
|
||||
switch (fmt) {
|
||||
case "datetime":
|
||||
return formatDateTime(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "datetimeseconds":
|
||||
return formatDateTimeWithSeconds(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "millisecond":
|
||||
return formatTimeWithSeconds(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "second":
|
||||
return formatTimeWithSeconds(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "minute":
|
||||
return formatTime(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "hour":
|
||||
return formatTime(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "weekday":
|
||||
return formatDateWeekdayDay(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "date":
|
||||
return formatDate(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "day":
|
||||
return formatDateVeryShort(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "week":
|
||||
return formatDateVeryShort(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "month":
|
||||
return formatDateMonth(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "monthyear":
|
||||
return formatDateMonthYear(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "quarter":
|
||||
return formatDate(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
case "year":
|
||||
return formatDateYear(
|
||||
new Date(time),
|
||||
this.options.locale,
|
||||
this.options.config
|
||||
);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
},
|
||||
// @ts-ignore
|
||||
add: (time, amount, unit) => {
|
||||
switch (unit) {
|
||||
case "millisecond":
|
||||
return addMilliseconds(time, amount);
|
||||
case "second":
|
||||
return addSeconds(time, amount);
|
||||
case "minute":
|
||||
return addMinutes(time, amount);
|
||||
case "hour":
|
||||
return addHours(time, amount);
|
||||
case "day":
|
||||
return addDays(time, amount);
|
||||
case "week":
|
||||
return addWeeks(time, amount);
|
||||
case "month":
|
||||
return addMonths(time, amount);
|
||||
case "quarter":
|
||||
return addQuarters(time, amount);
|
||||
case "year":
|
||||
return addYears(time, amount);
|
||||
default:
|
||||
return time;
|
||||
}
|
||||
},
|
||||
diff: (max, min, unit) => {
|
||||
switch (unit) {
|
||||
case "millisecond":
|
||||
return differenceInMilliseconds(max, min);
|
||||
case "second":
|
||||
return differenceInSeconds(max, min);
|
||||
case "minute":
|
||||
return differenceInMinutes(max, min);
|
||||
case "hour":
|
||||
return differenceInHours(max, min);
|
||||
case "day":
|
||||
return differenceInDays(max, min);
|
||||
case "week":
|
||||
return differenceInWeeks(max, min);
|
||||
case "month":
|
||||
return differenceInMonths(max, min);
|
||||
case "quarter":
|
||||
return differenceInQuarters(max, min);
|
||||
case "year":
|
||||
return differenceInYears(max, min);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
// @ts-ignore
|
||||
startOf: (time, unit, weekday) => {
|
||||
switch (unit) {
|
||||
case "second":
|
||||
return startOfSecond(time);
|
||||
case "minute":
|
||||
return startOfMinute(time);
|
||||
case "hour":
|
||||
return startOfHour(time);
|
||||
case "day":
|
||||
return startOfDay(time);
|
||||
case "week":
|
||||
return startOfWeek(time);
|
||||
case "isoWeek":
|
||||
return startOfWeek(time, {
|
||||
weekStartsOn: +weekday! as 0 | 1 | 2 | 3 | 4 | 5 | 6,
|
||||
});
|
||||
case "month":
|
||||
return startOfMonth(time);
|
||||
case "quarter":
|
||||
return startOfQuarter(time);
|
||||
case "year":
|
||||
return startOfYear(time);
|
||||
default:
|
||||
return time;
|
||||
}
|
||||
},
|
||||
// @ts-ignore
|
||||
endOf: (time, unit) => {
|
||||
switch (unit) {
|
||||
case "second":
|
||||
return endOfSecond(time);
|
||||
case "minute":
|
||||
return endOfMinute(time);
|
||||
case "hour":
|
||||
return endOfHour(time);
|
||||
case "day":
|
||||
return endOfDay(time);
|
||||
case "week":
|
||||
return endOfWeek(time);
|
||||
case "month":
|
||||
return endOfMonth(time);
|
||||
case "quarter":
|
||||
return endOfQuarter(time);
|
||||
case "year":
|
||||
return endOfYear(time);
|
||||
default:
|
||||
return time;
|
||||
}
|
||||
},
|
||||
});
|
@@ -1,6 +0,0 @@
|
||||
import type { ChartEvent } from "chart.js";
|
||||
|
||||
export const clickIsTouch = (event: ChartEvent): boolean =>
|
||||
!(event.native instanceof MouseEvent) ||
|
||||
(event.native instanceof PointerEvent &&
|
||||
event.native.pointerType !== "mouse");
|
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ 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";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
|
||||
export interface Node {
|
||||
id: string;
|
||||
@@ -68,15 +69,12 @@ export class HaSankeyChart extends LitElement {
|
||||
|
||||
private _statePerPixel = 0;
|
||||
|
||||
private _textMeasureCanvas?: HTMLCanvasElement;
|
||||
|
||||
private _sizeController = new ResizeController(this, {
|
||||
callback: (entries) => entries[0]?.contentRect,
|
||||
});
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._textMeasureCanvas = undefined;
|
||||
}
|
||||
|
||||
willUpdate() {
|
||||
@@ -477,7 +475,7 @@ export class HaSankeyChart extends LitElement {
|
||||
(node) =>
|
||||
NODE_WIDTH +
|
||||
TEXT_PADDING +
|
||||
(node.label ? this._getTextWidth(node.label) : 0)
|
||||
(node.label ? measureTextWidth(node.label, FONT_SIZE) : 0)
|
||||
)
|
||||
)
|
||||
: 0;
|
||||
@@ -492,18 +490,6 @@ export class HaSankeyChart extends LitElement {
|
||||
: 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
|
||||
@@ -513,7 +499,7 @@ export class HaSankeyChart extends LitElement {
|
||||
longest.length > current.length ? longest : current,
|
||||
""
|
||||
);
|
||||
const wordWidth = this._getTextWidth(longestWord);
|
||||
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
|
||||
return Math.min(FONT_SIZE, (labelWidth / wordWidth) * FONT_SIZE);
|
||||
}
|
||||
|
||||
|
@@ -1,19 +1,26 @@
|
||||
import type { ChartData, ChartDataset, ChartOptions } from "chart.js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import type { VisualMapComponentOption } from "echarts/components";
|
||||
import type { LineSeriesOption } from "echarts/charts";
|
||||
import type { YAXisOption } from "echarts/types/dist/shared";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import {
|
||||
formatNumber,
|
||||
numberFormatToLocale,
|
||||
getNumberFormatOptions,
|
||||
} from "../../common/number/format_number";
|
||||
|
||||
import type { LineChartEntity, LineChartState } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { clickIsTouch } from "./click_is_touch";
|
||||
import type { ECOption } from "../../resources/echarts";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
getNumberFormatOptions,
|
||||
formatNumber,
|
||||
} from "../../common/number/format_number";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
|
||||
const safeParseFloat = (value) => {
|
||||
const parsed = parseFloat(value);
|
||||
@@ -54,15 +61,19 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
|
||||
|
||||
@state() private _chartData?: ChartData<"line">;
|
||||
@property({ type: String }) public height?: string;
|
||||
|
||||
@state() private _chartData: LineSeriesOption[] = [];
|
||||
|
||||
@state() private _entityIds: string[] = [];
|
||||
|
||||
private _datasetToDataIndex: number[] = [];
|
||||
|
||||
@state() private _chartOptions?: ChartOptions;
|
||||
@state() private _chartOptions?: ECOption;
|
||||
|
||||
@state() private _yWidth = 0;
|
||||
private _hiddenStats = new Set<string>();
|
||||
|
||||
@state() private _yWidth = 25;
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
@@ -72,171 +83,109 @@ export class StateHistoryChartLine extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.data=${this._chartData}
|
||||
.options=${this._chartOptions}
|
||||
.paddingYAxis=${this.paddingYAxis - this._yWidth}
|
||||
chart-type="line"
|
||||
.height=${this.height}
|
||||
style=${styleMap({ height: this.height })}
|
||||
external-hidden
|
||||
@dataset-hidden=${this._datasetHidden}
|
||||
@dataset-unhidden=${this._datasetUnhidden}
|
||||
></ha-chart-base>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderTooltip(params: any) {
|
||||
const time = params[0].axisValue;
|
||||
const title =
|
||||
formatDateTimeWithSeconds(
|
||||
new Date(time),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
) + "<br>";
|
||||
const datapoints: Record<string, any>[] = [];
|
||||
this._chartData.forEach((dataset, index) => {
|
||||
if (
|
||||
dataset.tooltip?.show === false ||
|
||||
this._hiddenStats.has(dataset.name as string)
|
||||
)
|
||||
return;
|
||||
const param = params.find(
|
||||
(p: Record<string, any>) => p.seriesIndex === index
|
||||
);
|
||||
if (param) {
|
||||
datapoints.push(param);
|
||||
return;
|
||||
}
|
||||
// If the datapoint is not found, we need to find the last datapoint before the current time
|
||||
let lastData;
|
||||
const data = dataset.data || [];
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
const point = data[i];
|
||||
if (point && point[0] <= time && point[1]) {
|
||||
lastData = point;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!lastData) return;
|
||||
datapoints.push({
|
||||
seriesName: dataset.name,
|
||||
seriesIndex: index,
|
||||
value: lastData,
|
||||
// HTML copied from echarts. May change based on options
|
||||
marker: `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
|
||||
});
|
||||
});
|
||||
const unit = this.unit
|
||||
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
title +
|
||||
datapoints
|
||||
.map((param) => {
|
||||
const entityId = this._entityIds[param.seriesIndex];
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const entry = this.hass.entities[entityId];
|
||||
const stateValue = String(param.value[1]);
|
||||
let value = stateObj
|
||||
? this.hass.formatEntityState(stateObj, stateValue)
|
||||
: `${formatNumber(
|
||||
stateValue,
|
||||
this.hass.locale,
|
||||
getNumberFormatOptions(undefined, entry)
|
||||
)}${unit}`;
|
||||
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
|
||||
const data = this.data[dataIndex];
|
||||
if (data.statistics && data.statistics.length > 0) {
|
||||
value += "<br> ";
|
||||
const source =
|
||||
data.states.length === 0 ||
|
||||
param.value[0] < data.states[0].last_changed
|
||||
? `${this.hass.localize(
|
||||
"ui.components.history_charts.source_stats"
|
||||
)}`
|
||||
: `${this.hass.localize(
|
||||
"ui.components.history_charts.source_history"
|
||||
)}`;
|
||||
value += source;
|
||||
}
|
||||
|
||||
if (param.seriesName) {
|
||||
return `${param.marker} ${param.seriesName}: ${value}`;
|
||||
}
|
||||
return `${param.marker} ${value}`;
|
||||
})
|
||||
.join("<br>")
|
||||
);
|
||||
}
|
||||
|
||||
private _datasetHidden(ev: CustomEvent) {
|
||||
this._hiddenStats.add(ev.detail.name);
|
||||
}
|
||||
|
||||
private _datasetUnhidden(ev: CustomEvent) {
|
||||
this._hiddenStats.delete(ev.detail.name);
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
!this.hasUpdated ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("unit") ||
|
||||
changedProps.has("logarithmicScale") ||
|
||||
changedProps.has("minYAxis") ||
|
||||
changedProps.has("maxYAxis") ||
|
||||
changedProps.has("fitYData")
|
||||
) {
|
||||
this._chartOptions = {
|
||||
parsing: false,
|
||||
interaction: {
|
||||
mode: "nearest",
|
||||
axis: "xy",
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
adapters: {
|
||||
date: {
|
||||
locale: this.hass.locale,
|
||||
config: this.hass.config,
|
||||
},
|
||||
},
|
||||
min: this.startTime,
|
||||
max: this.endTime,
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
sampleSize: 5,
|
||||
autoSkipPadding: 20,
|
||||
major: {
|
||||
enabled: true,
|
||||
},
|
||||
font: (context) =>
|
||||
context.tick && context.tick.major
|
||||
? ({ weight: "bold" } as any)
|
||||
: {},
|
||||
},
|
||||
time: {
|
||||
tooltipFormat: "datetimeseconds",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
suggestedMin: this.fitYData ? this.minYAxis : null,
|
||||
suggestedMax: this.fitYData ? this.maxYAxis : null,
|
||||
min: this.fitYData ? null : this.minYAxis,
|
||||
max: this.fitYData ? null : this.maxYAxis,
|
||||
ticks: {
|
||||
maxTicksLimit: 7,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: this.unit,
|
||||
},
|
||||
afterUpdate: (y) => {
|
||||
if (this._yWidth !== Math.floor(y.width)) {
|
||||
this._yWidth = Math.floor(y.width);
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
position: computeRTL(this.hass) ? "right" : "left",
|
||||
type: this.logarithmicScale ? "logarithmic" : "linear",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
let label = `${context.dataset.label}: ${formatNumber(
|
||||
context.parsed.y,
|
||||
this.hass.locale,
|
||||
getNumberFormatOptions(
|
||||
undefined,
|
||||
this.hass.entities[this._entityIds[context.datasetIndex]]
|
||||
)
|
||||
)} ${this.unit}`;
|
||||
const dataIndex =
|
||||
this._datasetToDataIndex[context.datasetIndex];
|
||||
const data = this.data[dataIndex];
|
||||
if (data.statistics && data.statistics.length > 0) {
|
||||
const source =
|
||||
data.states.length === 0 ||
|
||||
context.parsed.x < data.states[0].last_changed
|
||||
? `\n${this.hass.localize(
|
||||
"ui.components.history_charts.source_stats"
|
||||
)}`
|
||||
: `\n${this.hass.localize(
|
||||
"ui.components.history_charts.source_history"
|
||||
)}`;
|
||||
label += source;
|
||||
}
|
||||
return label;
|
||||
},
|
||||
},
|
||||
},
|
||||
filler: {
|
||||
propagate: true,
|
||||
},
|
||||
legend: {
|
||||
display: this.showNames,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.1,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
point: {
|
||||
hitRadius: 50,
|
||||
},
|
||||
},
|
||||
segment: {
|
||||
borderColor: (context) => {
|
||||
// render stat data with a slightly transparent line
|
||||
const dataIndex = this._datasetToDataIndex[context.datasetIndex];
|
||||
const data = this.data[dataIndex];
|
||||
return data.statistics &&
|
||||
data.statistics.length > 0 &&
|
||||
(data.states.length === 0 ||
|
||||
context.p0.parsed.x < data.states[0].last_changed)
|
||||
? this._chartData!.datasets[dataIndex].borderColor + "7F"
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
// @ts-expect-error
|
||||
locale: numberFormatToLocale(this.hass.locale),
|
||||
onClick: (e: any) => {
|
||||
if (!this.clickForMoreInfo || clickIsTouch(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chart = e.chart;
|
||||
|
||||
const points = chart.getElementsAtEventForMode(
|
||||
e,
|
||||
"nearest",
|
||||
{ intersect: true },
|
||||
true
|
||||
);
|
||||
|
||||
if (points.length) {
|
||||
const firstPoint = points[0];
|
||||
fireEvent(this, "hass-more-info", {
|
||||
entityId: this._entityIds[firstPoint.datasetIndex],
|
||||
});
|
||||
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
changedProps.has("data") ||
|
||||
changedProps.has("startTime") ||
|
||||
@@ -248,13 +197,130 @@ export class StateHistoryChartLine extends LitElement {
|
||||
// so the X axis grows even if there is no new data
|
||||
this._generateData();
|
||||
}
|
||||
|
||||
if (
|
||||
!this.hasUpdated ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("unit") ||
|
||||
changedProps.has("logarithmicScale") ||
|
||||
changedProps.has("minYAxis") ||
|
||||
changedProps.has("maxYAxis") ||
|
||||
changedProps.has("fitYData") ||
|
||||
changedProps.has("_chartData") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth")
|
||||
) {
|
||||
const rtl = computeRTL(this.hass);
|
||||
let minYAxis: number | ((values: { min: number }) => number) | undefined =
|
||||
this.minYAxis;
|
||||
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
|
||||
this.maxYAxis;
|
||||
if (typeof minYAxis === "number") {
|
||||
if (this.fitYData) {
|
||||
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
|
||||
}
|
||||
} else if (this.logarithmicScale) {
|
||||
minYAxis = ({ min }) => (min > 0 ? min * 0.95 : min * 1.05);
|
||||
}
|
||||
if (typeof maxYAxis === "number") {
|
||||
if (this.fitYData) {
|
||||
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
|
||||
}
|
||||
} else if (this.logarithmicScale) {
|
||||
maxYAxis = ({ max }) => (max > 0 ? max * 1.05 : max * 0.95);
|
||||
}
|
||||
this._chartOptions = {
|
||||
xAxis: {
|
||||
type: "time",
|
||||
min: this.startTime,
|
||||
max: this.endTime,
|
||||
},
|
||||
yAxis: {
|
||||
type: this.logarithmicScale ? "log" : "value",
|
||||
name: this.unit,
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
position: rtl ? "right" : "left",
|
||||
scale: true,
|
||||
nameGap: 2,
|
||||
nameTextStyle: {
|
||||
align: "left",
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
margin: 5,
|
||||
formatter: (value: number) => {
|
||||
const label = formatNumber(value, this.hass.locale);
|
||||
const width = measureTextWidth(label, 12) + 5;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
},
|
||||
} as YAXisOption,
|
||||
legend: {
|
||||
show: this.showNames,
|
||||
type: "scroll",
|
||||
animationDurationUpdate: 400,
|
||||
icon: "circle",
|
||||
padding: [20, 0],
|
||||
},
|
||||
grid: {
|
||||
...(this.showNames ? {} : { top: 30 }), // undefined is the same as 0
|
||||
left: rtl ? 1 : Math.max(this.paddingYAxis, this._yWidth),
|
||||
right: rtl ? Math.max(this.paddingYAxis, this._yWidth) : 1,
|
||||
bottom: 30,
|
||||
},
|
||||
visualMap: this._chartData
|
||||
.map((_, seriesIndex) => {
|
||||
const dataIndex = this._datasetToDataIndex[seriesIndex];
|
||||
const data = this.data[dataIndex];
|
||||
if (!data.statistics || data.statistics.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// render stat data with a slightly transparent line
|
||||
const firstStateTS =
|
||||
data.states[0]?.last_changed ?? this.endTime.getTime();
|
||||
return {
|
||||
show: false,
|
||||
seriesIndex,
|
||||
dimension: 0,
|
||||
pieces: [
|
||||
{
|
||||
max: firstStateTS - 0.01,
|
||||
colorAlpha: 0.5,
|
||||
},
|
||||
{
|
||||
min: firstStateTS,
|
||||
colorAlpha: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as VisualMapComponentOption[],
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
appendTo: document.body,
|
||||
formatter: this._renderTooltip.bind(this),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private _generateData() {
|
||||
let colorIndex = 0;
|
||||
const computedStyles = getComputedStyle(this);
|
||||
const entityStates = this.data;
|
||||
const datasets: ChartDataset<"line">[] = [];
|
||||
const datasets: LineSeriesOption[] = [];
|
||||
const entityIds: string[] = [];
|
||||
const datasetToDataIndex: number[] = [];
|
||||
if (entityStates.length === 0) {
|
||||
@@ -270,7 +336,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
// array containing [value1, value2, etc]
|
||||
let prevValues: any[] | null = null;
|
||||
|
||||
const data: ChartDataset<"line">[] = [];
|
||||
const data: LineSeriesOption[] = [];
|
||||
|
||||
const pushData = (timestamp: Date, datavalues: any[] | null) => {
|
||||
if (!datavalues) return;
|
||||
@@ -287,26 +353,45 @@ export class StateHistoryChartLine extends LitElement {
|
||||
// to the chart for the previous value. Otherwise the gap will
|
||||
// be too big. It will go from the start of the previous data
|
||||
// value until the start of the next data value.
|
||||
d.data.push({ x: timestamp.getTime(), y: prevValues[i] });
|
||||
d.data!.push([timestamp, prevValues[i]]);
|
||||
}
|
||||
d.data.push({ x: timestamp.getTime(), y: datavalues[i] });
|
||||
d.data!.push([timestamp, datavalues[i]]);
|
||||
});
|
||||
prevValues = datavalues;
|
||||
};
|
||||
|
||||
const addDataSet = (nameY: string, color?: string, fill = false) => {
|
||||
const addDataSet = (
|
||||
id: string,
|
||||
nameY: string,
|
||||
color?: string,
|
||||
fill = false
|
||||
) => {
|
||||
if (!color) {
|
||||
color = getGraphColorByIndex(colorIndex, computedStyles);
|
||||
colorIndex++;
|
||||
}
|
||||
data.push({
|
||||
label: nameY,
|
||||
fill: fill ? "origin" : false,
|
||||
borderColor: color,
|
||||
backgroundColor: color + "7F",
|
||||
stepped: "before",
|
||||
pointRadius: 0,
|
||||
id,
|
||||
data: [],
|
||||
type: "line",
|
||||
cursor: "default",
|
||||
name: nameY,
|
||||
color,
|
||||
symbol: "circle",
|
||||
step: "end",
|
||||
animationDurationUpdate: 0,
|
||||
symbolSize: 1,
|
||||
lineStyle: {
|
||||
width: fill ? 0 : 1.5,
|
||||
},
|
||||
areaStyle: fill
|
||||
? {
|
||||
color: color + "7F",
|
||||
}
|
||||
: undefined,
|
||||
tooltip: {
|
||||
show: !fill,
|
||||
},
|
||||
});
|
||||
entityIds.push(states.entity_id);
|
||||
datasetToDataIndex.push(dataIdx);
|
||||
@@ -324,12 +409,16 @@ export class StateHistoryChartLine extends LitElement {
|
||||
const isHeating =
|
||||
domain === "climate" && hasHvacAction
|
||||
? (entityState: LineChartState) =>
|
||||
entityState.attributes?.hvac_action === "heating"
|
||||
CLIMATE_HVAC_ACTION_TO_MODE[
|
||||
entityState.attributes?.hvac_action
|
||||
] === "heat"
|
||||
: (entityState: LineChartState) => entityState.state === "heat";
|
||||
const isCooling =
|
||||
domain === "climate" && hasHvacAction
|
||||
? (entityState: LineChartState) =>
|
||||
entityState.attributes?.hvac_action === "cooling"
|
||||
CLIMATE_HVAC_ACTION_TO_MODE[
|
||||
entityState.attributes?.hvac_action
|
||||
] === "cool"
|
||||
: (entityState: LineChartState) => entityState.state === "cool";
|
||||
|
||||
const hasHeat = states.states.some(isHeating);
|
||||
@@ -345,13 +434,23 @@ export class StateHistoryChartLine extends LitElement {
|
||||
entityState.attributes.target_temp_low
|
||||
);
|
||||
addDataSet(
|
||||
`${this.hass.localize("ui.card.climate.current_temperature", {
|
||||
name: name,
|
||||
})}`
|
||||
states.entity_id + "-current_temperature",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.climate.current_temperature", {
|
||||
name: name,
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.current_temperature.name"
|
||||
)
|
||||
);
|
||||
if (hasHeat) {
|
||||
addDataSet(
|
||||
`${this.hass.localize("ui.card.climate.heating", { name: name })}`,
|
||||
states.entity_id + "-heating",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.climate.heating", { name: name })
|
||||
: this.hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.hvac_action.state.heating"
|
||||
),
|
||||
computedStyles.getPropertyValue("--state-climate-heat-color"),
|
||||
true
|
||||
);
|
||||
@@ -360,7 +459,12 @@ export class StateHistoryChartLine extends LitElement {
|
||||
}
|
||||
if (hasCool) {
|
||||
addDataSet(
|
||||
`${this.hass.localize("ui.card.climate.cooling", { name: name })}`,
|
||||
states.entity_id + "-cooling",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.climate.cooling", { name: name })
|
||||
: this.hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.hvac_action.state.cooling"
|
||||
),
|
||||
computedStyles.getPropertyValue("--state-climate-cool-color"),
|
||||
true
|
||||
);
|
||||
@@ -370,22 +474,40 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
if (hasTargetRange) {
|
||||
addDataSet(
|
||||
`${this.hass.localize("ui.card.climate.target_temperature_mode", {
|
||||
name: name,
|
||||
mode: this.hass.localize("ui.card.climate.high"),
|
||||
})}`
|
||||
states.entity_id + "-target_temperature_mode",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.climate.target_temperature_mode", {
|
||||
name: name,
|
||||
mode: this.hass.localize("ui.card.climate.high"),
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.target_temp_high.name"
|
||||
)
|
||||
);
|
||||
addDataSet(
|
||||
`${this.hass.localize("ui.card.climate.target_temperature_mode", {
|
||||
name: name,
|
||||
mode: this.hass.localize("ui.card.climate.low"),
|
||||
})}`
|
||||
states.entity_id + "-target_temperature_mode_low",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.climate.target_temperature_mode", {
|
||||
name: name,
|
||||
mode: this.hass.localize("ui.card.climate.low"),
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.target_temp_low.name"
|
||||
)
|
||||
);
|
||||
} else {
|
||||
addDataSet(
|
||||
`${this.hass.localize("ui.card.climate.target_temperature_entity", {
|
||||
name: name,
|
||||
})}`
|
||||
states.entity_id + "-target_temperature",
|
||||
this.showNames
|
||||
? this.hass.localize(
|
||||
"ui.card.climate.target_temperature_entity",
|
||||
{
|
||||
name: name,
|
||||
}
|
||||
)
|
||||
: this.hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.temperature.name"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -438,19 +560,29 @@ export class StateHistoryChartLine extends LitElement {
|
||||
);
|
||||
|
||||
addDataSet(
|
||||
`${this.hass.localize("ui.card.humidifier.target_humidity_entity", {
|
||||
name: name,
|
||||
})}`
|
||||
states.entity_id + "-target_humidity",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.humidifier.target_humidity_entity", {
|
||||
name: name,
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.humidifier.entity_component._.state_attributes.humidity.name"
|
||||
)
|
||||
);
|
||||
|
||||
if (hasCurrent) {
|
||||
addDataSet(
|
||||
`${this.hass.localize(
|
||||
"ui.card.humidifier.current_humidity_entity",
|
||||
{
|
||||
name: name,
|
||||
}
|
||||
)}`
|
||||
states.entity_id + "-current_humidity",
|
||||
this.showNames
|
||||
? this.hass.localize(
|
||||
"ui.card.humidifier.current_humidity_entity",
|
||||
{
|
||||
name: name,
|
||||
}
|
||||
)
|
||||
: this.hass.localize(
|
||||
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -458,25 +590,40 @@ export class StateHistoryChartLine extends LitElement {
|
||||
// If action attribute is not available, we shade the area when the device is on
|
||||
if (hasHumidifying) {
|
||||
addDataSet(
|
||||
`${this.hass.localize("ui.card.humidifier.humidifying", {
|
||||
name: name,
|
||||
})}`,
|
||||
states.entity_id + "-humidifying",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.humidifier.humidifying", {
|
||||
name: name,
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
|
||||
),
|
||||
computedStyles.getPropertyValue("--state-humidifier-on-color"),
|
||||
true
|
||||
);
|
||||
} else if (hasDrying) {
|
||||
addDataSet(
|
||||
`${this.hass.localize("ui.card.humidifier.drying", {
|
||||
name: name,
|
||||
})}`,
|
||||
states.entity_id + "-drying",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.humidifier.drying", {
|
||||
name: name,
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.humidifier.entity_component._.state_attributes.action.state.drying"
|
||||
),
|
||||
computedStyles.getPropertyValue("--state-humidifier-on-color"),
|
||||
true
|
||||
);
|
||||
} else {
|
||||
addDataSet(
|
||||
`${this.hass.localize("ui.card.humidifier.on_entity", {
|
||||
name: name,
|
||||
})}`,
|
||||
states.entity_id + "-on",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.humidifier.on_entity", {
|
||||
name: name,
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.humidifier.entity_component._.state.on"
|
||||
),
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
@@ -509,7 +656,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
});
|
||||
} else {
|
||||
addDataSet(name);
|
||||
addDataSet(states.entity_id, name);
|
||||
|
||||
let lastValue: number;
|
||||
let lastDate: Date;
|
||||
@@ -575,12 +722,23 @@ export class StateHistoryChartLine extends LitElement {
|
||||
Array.prototype.push.apply(datasets, data);
|
||||
});
|
||||
|
||||
this._chartData = {
|
||||
datasets,
|
||||
};
|
||||
this._chartData = datasets;
|
||||
this._entityIds = entityIds;
|
||||
this._datasetToDataIndex = datasetToDataIndex;
|
||||
}
|
||||
|
||||
private _clampYAxis(value?: number | ((values: any) => number)) {
|
||||
if (this.logarithmicScale) {
|
||||
// log(0) is -Infinity, so we need to set a minimum value
|
||||
if (typeof value === "number") {
|
||||
return Math.max(value, 0.1);
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
return (values: any) => Math.max(value(values), 0.1);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
customElements.define("state-history-chart-line", StateHistoryChartLine);
|
||||
|
||||
|
@@ -1,19 +1,26 @@
|
||||
import type { ChartData, ChartDataset, ChartOptions } from "chart.js";
|
||||
import { getRelativePosition } from "chart.js/helpers";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type {
|
||||
CustomSeriesOption,
|
||||
CustomSeriesRenderItem,
|
||||
ECElementEvent,
|
||||
TooltipFormatterCallback,
|
||||
TooltipPositionCallbackParams,
|
||||
} from "echarts/types/dist/shared";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { numberFormatToLocale } from "../../common/number/format_number";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import type { TimeLineData } from "./timeline-chart/const";
|
||||
import { computeTimelineColor } from "./timeline-chart/timeline-color";
|
||||
import { clickIsTouch } from "./click_is_touch";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { ECOption } from "../../resources/echarts";
|
||||
import echarts from "../../resources/echarts";
|
||||
import { luminosity } from "../../common/color/rgb";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
|
||||
@customElement("state-history-chart-timeline")
|
||||
export class StateHistoryChartTimeline extends LitElement {
|
||||
@@ -44,9 +51,9 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@property({ attribute: false, type: Number }) public chartIndex?;
|
||||
|
||||
@state() private _chartData?: ChartData<"timeline">;
|
||||
@state() private _chartData: CustomSeriesOption[] = [];
|
||||
|
||||
@state() private _chartOptions?: ChartOptions<"timeline">;
|
||||
@state() private _chartOptions?: ECOption;
|
||||
|
||||
@state() private _yWidth = 0;
|
||||
|
||||
@@ -56,20 +63,99 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
return html`
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.data=${this._chartData}
|
||||
.options=${this._chartOptions}
|
||||
.height=${this.data.length * 30 + 30}
|
||||
.paddingYAxis=${this.paddingYAxis - this._yWidth}
|
||||
chart-type="timeline"
|
||||
.height=${`${this.data.length * 30 + 30}px`}
|
||||
.data=${this._chartData as ECOption["series"]}
|
||||
@chart-click=${this._handleChartClick}
|
||||
></ha-chart-base>
|
||||
`;
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (!this.hasUpdated) {
|
||||
this._createOptions();
|
||||
private _renderItem: CustomSeriesRenderItem = (params, api) => {
|
||||
const categoryIndex = api.value(0);
|
||||
const start = api.coord([api.value(1), categoryIndex]);
|
||||
const end = api.coord([api.value(2), categoryIndex]);
|
||||
const height = 20;
|
||||
const coordSys = params.coordSys as any;
|
||||
const rectShape = echarts.graphic.clipRectByRect(
|
||||
{
|
||||
x: start[0],
|
||||
y: start[1] - height / 2,
|
||||
width: end[0] - start[0],
|
||||
height: height,
|
||||
},
|
||||
{
|
||||
x: coordSys.x,
|
||||
y: coordSys.y,
|
||||
width: coordSys.width,
|
||||
height: coordSys.height,
|
||||
}
|
||||
);
|
||||
if (!rectShape) return null;
|
||||
const rect = {
|
||||
type: "rect" as const,
|
||||
transition: "shape" as const,
|
||||
shape: rectShape,
|
||||
style: {
|
||||
fill: api.value(4) as string,
|
||||
},
|
||||
};
|
||||
const text = api.value(3) as string;
|
||||
const textWidth = measureTextWidth(text, 12);
|
||||
const LABEL_PADDING = 4;
|
||||
if (textWidth < rectShape.width - LABEL_PADDING * 2) {
|
||||
return {
|
||||
type: "group",
|
||||
children: [
|
||||
rect,
|
||||
{
|
||||
type: "text",
|
||||
style: {
|
||||
...rectShape,
|
||||
x: rectShape.x + LABEL_PADDING,
|
||||
text,
|
||||
fill: api.value(5) as string,
|
||||
fontSize: 12,
|
||||
lineHeight: rectShape.height,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return rect;
|
||||
};
|
||||
|
||||
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
|
||||
(params: TooltipPositionCallbackParams) => {
|
||||
const { value, name, marker, seriesName } = Array.isArray(params)
|
||||
? params[0]
|
||||
: params;
|
||||
const title = seriesName
|
||||
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
|
||||
: "";
|
||||
const durationInMs = value![2] - value![1];
|
||||
const formattedDuration = `${this.hass.localize(
|
||||
"ui.components.history_charts.duration"
|
||||
)}: ${millisecondsToDuration(durationInMs)}`;
|
||||
|
||||
const lines = [
|
||||
marker + name,
|
||||
formatDateTimeWithSeconds(
|
||||
new Date(value![1]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
formatDateTimeWithSeconds(
|
||||
new Date(value![2]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
formattedDuration,
|
||||
].join("<br>");
|
||||
return [title, lines].join("");
|
||||
};
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
@@ -83,9 +169,12 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
if (
|
||||
!this.hasUpdated ||
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames")
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth")
|
||||
) {
|
||||
this._createOptions();
|
||||
}
|
||||
@@ -93,144 +182,71 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
private _createOptions() {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const labelWidth = showNames
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(this.hass);
|
||||
this._chartOptions = {
|
||||
maintainAspectRatio: false,
|
||||
parsing: false,
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
position: "bottom",
|
||||
adapters: {
|
||||
date: {
|
||||
locale: this.hass.locale,
|
||||
config: this.hass.config,
|
||||
},
|
||||
},
|
||||
min: this.startTime,
|
||||
suggestedMax: this.endTime,
|
||||
ticks: {
|
||||
autoSkip: true,
|
||||
maxRotation: 0,
|
||||
sampleSize: 5,
|
||||
autoSkipPadding: 20,
|
||||
major: {
|
||||
enabled: true,
|
||||
},
|
||||
font: (context) =>
|
||||
context.tick && context.tick.major
|
||||
? ({ weight: "bold" } as any)
|
||||
: {},
|
||||
},
|
||||
grid: {
|
||||
offset: false,
|
||||
},
|
||||
time: {
|
||||
tooltipFormat: "datetimeseconds",
|
||||
},
|
||||
xAxis: {
|
||||
type: "time",
|
||||
min: this.startTime,
|
||||
max: this.endTime,
|
||||
axisTick: {
|
||||
show: true,
|
||||
},
|
||||
y: {
|
||||
type: "category",
|
||||
barThickness: 20,
|
||||
offset: true,
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false,
|
||||
drawTicks: false,
|
||||
},
|
||||
ticks: {
|
||||
display: this.chunked || this.showNames,
|
||||
},
|
||||
afterSetDimensions: (y) => {
|
||||
y.maxWidth = y.chart.width * 0.18;
|
||||
},
|
||||
afterFit: (scaleInstance) => {
|
||||
if (this.chunked) {
|
||||
// ensure all the chart labels are the same width
|
||||
scaleInstance.width = narrow ? 105 : 185;
|
||||
}
|
||||
},
|
||||
afterUpdate: (y) => {
|
||||
const yWidth = this.showNames
|
||||
? (y.width ?? 0)
|
||||
: computeRTL(this.hass)
|
||||
? 0
|
||||
: (y.left ?? 0);
|
||||
if (
|
||||
this._yWidth !== Math.floor(yWidth) &&
|
||||
y.ticks.length === this.data.length
|
||||
) {
|
||||
this._yWidth = Math.floor(yWidth);
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "category",
|
||||
inverse: true,
|
||||
position: rtl ? "right" : "left",
|
||||
triggerEvent: true,
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
position: computeRTL(this.hass) ? "right" : "left",
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
mode: "nearest",
|
||||
callbacks: {
|
||||
title: (context) =>
|
||||
context![0].chart!.data!.labels![
|
||||
context[0].datasetIndex
|
||||
] as string,
|
||||
beforeBody: (context) => context[0].dataset.label || "",
|
||||
label: (item) => {
|
||||
const d = item.dataset.data[item.dataIndex] as TimeLineData;
|
||||
const durationInMs = d.end.getTime() - d.start.getTime();
|
||||
const formattedDuration = `${this.hass.localize(
|
||||
"ui.components.history_charts.duration"
|
||||
)}: ${millisecondsToDuration(durationInMs)}`;
|
||||
|
||||
return [
|
||||
d.label || "",
|
||||
formatDateTimeWithSeconds(
|
||||
d.start,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
formatDateTimeWithSeconds(
|
||||
d.end,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
formattedDuration,
|
||||
];
|
||||
},
|
||||
labelColor: (item) => ({
|
||||
borderColor: (item.dataset.data[item.dataIndex] as TimeLineData)
|
||||
.color!,
|
||||
backgroundColor: (
|
||||
item.dataset.data[item.dataIndex] as TimeLineData
|
||||
).color!,
|
||||
}),
|
||||
},
|
||||
},
|
||||
filler: {
|
||||
propagate: true,
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
bottom: 30,
|
||||
left: rtl ? 1 : labelWidth,
|
||||
right: rtl ? labelWidth : 1,
|
||||
},
|
||||
// @ts-expect-error
|
||||
locale: numberFormatToLocale(this.hass.locale),
|
||||
onClick: (e: any) => {
|
||||
if (!this.clickForMoreInfo || clickIsTouch(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chart = e.chart;
|
||||
const canvasPosition = getRelativePosition(e, chart);
|
||||
|
||||
const index = Math.abs(
|
||||
chart.scales.y.getValueForPixel(canvasPosition.y)
|
||||
);
|
||||
fireEvent(this, "hass-more-info", {
|
||||
// @ts-ignore
|
||||
entityId: this._chartData?.datasets[index]?.label,
|
||||
});
|
||||
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
|
||||
tooltip: {
|
||||
appendTo: document.body,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -246,8 +262,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
this._chartTime = new Date();
|
||||
const startTime = this.startTime;
|
||||
const endTime = this.endTime;
|
||||
const labels: string[] = [];
|
||||
const datasets: ChartDataset<"timeline">[] = [];
|
||||
const datasets: CustomSeriesOption[] = [];
|
||||
const names = this.names || {};
|
||||
// stateHistory is a list of lists of sorted state objects
|
||||
stateHistory.forEach((stateInfo) => {
|
||||
@@ -255,10 +270,11 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
let prevState: string | null = null;
|
||||
let locState: string | null = null;
|
||||
let prevLastChanged = startTime;
|
||||
const entityDisplay: string =
|
||||
names[stateInfo.entity_id] || stateInfo.name;
|
||||
const entityDisplay: string = this.showNames
|
||||
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
|
||||
: "";
|
||||
|
||||
const dataRow: TimeLineData[] = [];
|
||||
const dataRow: unknown[] = [];
|
||||
stateInfo.data.forEach((entityState) => {
|
||||
let newState: string | null = entityState.state;
|
||||
const timeStamp = new Date(entityState.last_changed);
|
||||
@@ -277,15 +293,23 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
} else if (newState !== prevState) {
|
||||
newLastChanged = new Date(entityState.last_changed);
|
||||
|
||||
const color = computeTimelineColor(
|
||||
prevState,
|
||||
computedStyles,
|
||||
this.hass.states[stateInfo.entity_id]
|
||||
);
|
||||
dataRow.push({
|
||||
start: prevLastChanged,
|
||||
end: newLastChanged,
|
||||
label: locState,
|
||||
color: computeTimelineColor(
|
||||
prevState,
|
||||
computedStyles,
|
||||
this.hass.states[stateInfo.entity_id]
|
||||
),
|
||||
value: [
|
||||
stateInfo.entity_id,
|
||||
prevLastChanged,
|
||||
newLastChanged,
|
||||
locState,
|
||||
color,
|
||||
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
|
||||
],
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
});
|
||||
|
||||
prevState = newState;
|
||||
@@ -295,28 +319,52 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
});
|
||||
|
||||
if (prevState !== null) {
|
||||
const color = computeTimelineColor(
|
||||
prevState,
|
||||
computedStyles,
|
||||
this.hass.states[stateInfo.entity_id]
|
||||
);
|
||||
dataRow.push({
|
||||
start: prevLastChanged,
|
||||
end: endTime,
|
||||
label: locState,
|
||||
color: computeTimelineColor(
|
||||
prevState,
|
||||
computedStyles,
|
||||
this.hass.states[stateInfo.entity_id]
|
||||
),
|
||||
value: [
|
||||
stateInfo.entity_id,
|
||||
prevLastChanged,
|
||||
endTime,
|
||||
locState,
|
||||
color,
|
||||
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
|
||||
],
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
});
|
||||
}
|
||||
datasets.push({
|
||||
id: stateInfo.entity_id,
|
||||
data: dataRow,
|
||||
label: stateInfo.entity_id,
|
||||
name: entityDisplay,
|
||||
dimensions: ["id", "start", "end", "name", "color", "textColor"],
|
||||
type: "custom",
|
||||
encode: {
|
||||
x: [1, 2],
|
||||
y: 0,
|
||||
itemName: 3,
|
||||
},
|
||||
renderItem: this._renderItem,
|
||||
});
|
||||
labels.push(entityDisplay);
|
||||
});
|
||||
|
||||
this._chartData = {
|
||||
labels: labels,
|
||||
datasets: datasets,
|
||||
};
|
||||
this._chartData = datasets;
|
||||
}
|
||||
|
||||
private _handleChartClick(e: CustomEvent<ECElementEvent>): void {
|
||||
if (e.detail.targetType === "axisLabel") {
|
||||
const dataset = this._chartData[e.detail.dataIndex];
|
||||
if (dataset) {
|
||||
fireEvent(this, "hass-more-info", {
|
||||
entityId: dataset.id as string,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
@@ -69,6 +69,8 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
|
||||
|
||||
@property({ type: String }) public height?: string;
|
||||
|
||||
private _computedStartTime!: Date;
|
||||
|
||||
private _computedEndTime!: Date;
|
||||
@@ -133,7 +135,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
return html``;
|
||||
}
|
||||
if (!Array.isArray(item)) {
|
||||
return html`<div class="entry-container">
|
||||
return html`<div class="entry-container line">
|
||||
<state-history-chart-line
|
||||
.hass=${this.hass}
|
||||
.unit=${item.unit}
|
||||
@@ -151,10 +153,11 @@ export class StateHistoryCharts extends LitElement {
|
||||
.maxYAxis=${this.maxYAxis}
|
||||
.fitYData=${this.fitYData}
|
||||
@y-width-changed=${this._yWidthChanged}
|
||||
.height=${this.virtualize ? undefined : this.height}
|
||||
></state-history-chart-line>
|
||||
</div> `;
|
||||
}
|
||||
return html`<div class="entry-container">
|
||||
return html`<div class="entry-container timeline">
|
||||
<state-history-chart-timeline
|
||||
.hass=${this.hass}
|
||||
.data=${item}
|
||||
@@ -274,7 +277,8 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* height of single timeline chart = 60px */
|
||||
min-height: 60px;
|
||||
}
|
||||
@@ -297,6 +301,10 @@ export class StateHistoryCharts extends LitElement {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.entry-container.line {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.entry-container:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -308,6 +316,10 @@ export class StateHistoryCharts extends LitElement {
|
||||
padding-inline-end: 1px;
|
||||
}
|
||||
|
||||
.entry-container.timeline:first-child {
|
||||
margin-top: var(--timeline-top-margin);
|
||||
}
|
||||
|
||||
.entry-container:not(:first-child) {
|
||||
border-top: 2px solid var(--divider-color);
|
||||
margin-top: 16px;
|
||||
|
@@ -1,21 +1,22 @@
|
||||
import type {
|
||||
ChartData,
|
||||
ChartDataset,
|
||||
ChartOptions,
|
||||
ChartType,
|
||||
} from "chart.js";
|
||||
BarSeriesOption,
|
||||
LineSeriesOption,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
formatNumber,
|
||||
numberFormatToLocale,
|
||||
getNumberFormatOptions,
|
||||
} from "../../common/number/format_number";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type {
|
||||
Statistics,
|
||||
StatisticsMetaData,
|
||||
@@ -25,13 +26,11 @@ import {
|
||||
getDisplayUnit,
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
statisticsHaveType,
|
||||
} from "../../data/recorder";
|
||||
import type { ECOption } from "../../resources/echarts";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-chart-base";
|
||||
import type { ChartDatasetExtra } from "./ha-chart-base";
|
||||
import { clickIsTouch } from "./click_is_touch";
|
||||
|
||||
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||
mean: "mean",
|
||||
@@ -57,12 +56,14 @@ export class StatisticsChart extends LitElement {
|
||||
|
||||
@property() public unit?: string;
|
||||
|
||||
@property({ attribute: false }) public startTime?: Date;
|
||||
|
||||
@property({ attribute: false }) public endTime?: Date;
|
||||
|
||||
@property({ attribute: false, type: Array })
|
||||
public statTypes: StatisticType[] = ["sum", "min", "mean", "max"];
|
||||
|
||||
@property({ attribute: false }) public chartType: ChartType = "line";
|
||||
@property({ attribute: false }) public chartType: "line" | "bar" = "line";
|
||||
|
||||
@property({ attribute: false, type: Number }) public minYAxis?: number;
|
||||
|
||||
@@ -84,13 +85,18 @@ export class StatisticsChart extends LitElement {
|
||||
|
||||
@property() public period?: string;
|
||||
|
||||
@state() private _chartData: ChartData = { datasets: [] };
|
||||
@property({ attribute: "days-to-show", type: Number })
|
||||
public daysToShow?: number;
|
||||
|
||||
@state() private _chartDatasetExtra: ChartDatasetExtra[] = [];
|
||||
@property({ type: String }) public height?: string;
|
||||
|
||||
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
|
||||
@state() private _legendData: string[] = [];
|
||||
|
||||
@state() private _statisticIds: string[] = [];
|
||||
|
||||
@state() private _chartOptions?: ChartOptions;
|
||||
@state() private _chartOptions?: ECOption;
|
||||
|
||||
@state() private _hiddenStats = new Set<string>();
|
||||
|
||||
@@ -101,8 +107,14 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (changedProps.has("legendMode")) {
|
||||
this._hiddenStats.clear();
|
||||
if (
|
||||
changedProps.has("statisticsData") ||
|
||||
changedProps.has("statTypes") ||
|
||||
changedProps.has("chartType") ||
|
||||
changedProps.has("hideLegend") ||
|
||||
changedProps.has("_hiddenStats")
|
||||
) {
|
||||
this._generateData();
|
||||
}
|
||||
if (
|
||||
!this.hasUpdated ||
|
||||
@@ -113,19 +125,14 @@ export class StatisticsChart extends LitElement {
|
||||
changedProps.has("maxYAxis") ||
|
||||
changedProps.has("fitYData") ||
|
||||
changedProps.has("logarithmicScale") ||
|
||||
changedProps.has("hideLegend")
|
||||
changedProps.has("hideLegend") ||
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("_legendData") ||
|
||||
changedProps.has("_chartData")
|
||||
) {
|
||||
this._createOptions();
|
||||
}
|
||||
if (
|
||||
changedProps.has("statisticsData") ||
|
||||
changedProps.has("statTypes") ||
|
||||
changedProps.has("chartType") ||
|
||||
changedProps.has("hideLegend") ||
|
||||
changedProps.has("_hiddenStats")
|
||||
) {
|
||||
this._generateData();
|
||||
}
|
||||
}
|
||||
|
||||
public firstUpdated() {
|
||||
@@ -157,145 +164,159 @@ export class StatisticsChart extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-chart-base
|
||||
external-hidden
|
||||
.hass=${this.hass}
|
||||
.data=${this._chartData}
|
||||
.extraData=${this._chartDatasetExtra}
|
||||
.options=${this._chartOptions}
|
||||
.chartType=${this.chartType}
|
||||
.height=${this.height}
|
||||
style=${styleMap({ height: this.height })}
|
||||
external-hidden
|
||||
@dataset-hidden=${this._datasetHidden}
|
||||
@dataset-unhidden=${this._datasetUnhidden}
|
||||
></ha-chart-base>
|
||||
`;
|
||||
}
|
||||
|
||||
private _datasetHidden(ev) {
|
||||
ev.stopPropagation();
|
||||
this._hiddenStats.add(this._statisticIds[ev.detail.index]);
|
||||
private _datasetHidden(ev: CustomEvent) {
|
||||
this._hiddenStats.add(ev.detail.name);
|
||||
this.requestUpdate("_hiddenStats");
|
||||
}
|
||||
|
||||
private _datasetUnhidden(ev) {
|
||||
ev.stopPropagation();
|
||||
this._hiddenStats.delete(this._statisticIds[ev.detail.index]);
|
||||
private _datasetUnhidden(ev: CustomEvent) {
|
||||
this._hiddenStats.delete(ev.detail.name);
|
||||
this.requestUpdate("_hiddenStats");
|
||||
}
|
||||
|
||||
private _createOptions(unit?: string) {
|
||||
this._chartOptions = {
|
||||
parsing: false,
|
||||
interaction: {
|
||||
mode: "nearest",
|
||||
axis: "x",
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
adapters: {
|
||||
date: {
|
||||
locale: this.hass.locale,
|
||||
config: this.hass.config,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
source: this.chartType === "bar" ? "data" : undefined,
|
||||
maxRotation: 0,
|
||||
sampleSize: 5,
|
||||
autoSkipPadding: 20,
|
||||
major: {
|
||||
enabled: true,
|
||||
},
|
||||
font: (context) =>
|
||||
context.tick && context.tick.major
|
||||
? ({ weight: "bold" } as any)
|
||||
: {},
|
||||
},
|
||||
time: {
|
||||
tooltipFormat: "datetime",
|
||||
unit:
|
||||
this.chartType === "bar" &&
|
||||
this.period &&
|
||||
["hour", "day", "week", "month"].includes(this.period)
|
||||
? this.period
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: this.chartType === "bar",
|
||||
ticks: {
|
||||
maxTicksLimit: 7,
|
||||
},
|
||||
title: {
|
||||
display: unit || this.unit,
|
||||
text: unit || this.unit,
|
||||
},
|
||||
type: this.logarithmicScale ? "logarithmic" : "linear",
|
||||
min: this.fitYData ? null : this.minYAxis,
|
||||
max: this.fitYData ? null : this.maxYAxis,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) =>
|
||||
`${context.dataset.label}: ${formatNumber(
|
||||
context.parsed.y,
|
||||
private _renderTooltip = (params: any) => {
|
||||
const rendered: Record<string, boolean> = {};
|
||||
const unit = this.unit
|
||||
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
|
||||
: "";
|
||||
return params
|
||||
.map((param, index: number) => {
|
||||
if (rendered[param.seriesName]) return "";
|
||||
rendered[param.seriesName] = true;
|
||||
|
||||
const statisticId = this._statisticIds[param.seriesIndex];
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
const entry = this.hass.entities[statisticId];
|
||||
// max series can have 3 values, as the second value is the max-min to form a band
|
||||
const rawValue = String(param.value[2] ?? param.value[1]);
|
||||
|
||||
const options = getNumberFormatOptions(stateObj, entry) ?? {
|
||||
maximumFractionDigits: 2,
|
||||
};
|
||||
|
||||
const value = `${formatNumber(
|
||||
rawValue,
|
||||
this.hass.locale,
|
||||
options
|
||||
)}${unit}`;
|
||||
|
||||
const time =
|
||||
index === 0
|
||||
? formatDateTimeWithSeconds(
|
||||
new Date(param.value[0]),
|
||||
this.hass.locale,
|
||||
getNumberFormatOptions(
|
||||
undefined,
|
||||
this.hass.entities[this._statisticIds[context.datasetIndex]]
|
||||
)
|
||||
)} ${
|
||||
// @ts-ignore
|
||||
context.dataset.unit || ""
|
||||
}`,
|
||||
},
|
||||
},
|
||||
filler: {
|
||||
propagate: true,
|
||||
},
|
||||
legend: {
|
||||
display: !this.hideLegend,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.4,
|
||||
cubicInterpolationMode: "monotone",
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
bar: { borderWidth: 1.5, borderRadius: 4 },
|
||||
point: {
|
||||
hitRadius: 50,
|
||||
},
|
||||
},
|
||||
// @ts-expect-error
|
||||
locale: numberFormatToLocale(this.hass.locale),
|
||||
onClick: (e: any) => {
|
||||
if (!this.clickForMoreInfo || clickIsTouch(e)) {
|
||||
return;
|
||||
this.hass.config
|
||||
) + "<br>"
|
||||
: "";
|
||||
return `${time}${param.marker} ${param.seriesName}: ${value}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("<br>");
|
||||
};
|
||||
|
||||
private _createOptions() {
|
||||
const dayDifference = this.daysToShow ?? 1;
|
||||
let minYAxis: number | ((values: { min: number }) => number) | undefined =
|
||||
this.minYAxis;
|
||||
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
|
||||
this.maxYAxis;
|
||||
if (typeof minYAxis === "number") {
|
||||
if (this.fitYData) {
|
||||
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
|
||||
}
|
||||
} else if (this.logarithmicScale) {
|
||||
minYAxis = ({ min }) => (min > 0 ? min * 0.95 : min * 1.05);
|
||||
}
|
||||
if (typeof maxYAxis === "number") {
|
||||
if (this.fitYData) {
|
||||
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
|
||||
}
|
||||
} else if (this.logarithmicScale) {
|
||||
maxYAxis = ({ max }) => (max > 0 ? max * 1.05 : max * 0.95);
|
||||
}
|
||||
const endTime = this.endTime ?? new Date();
|
||||
let startTime = this.startTime;
|
||||
|
||||
if (!startTime) {
|
||||
// set start time to the earliest point in the chart data
|
||||
this._chartData.forEach((series) => {
|
||||
if (!Array.isArray(series.data) || !series.data[0]) return;
|
||||
const firstPoint = series.data[0] as any;
|
||||
const timestamp = Array.isArray(firstPoint)
|
||||
? firstPoint[0]
|
||||
: firstPoint.value?.[0];
|
||||
if (timestamp && (!startTime || new Date(timestamp) < startTime)) {
|
||||
startTime = new Date(timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
const chart = e.chart;
|
||||
|
||||
const points = chart.getElementsAtEventForMode(
|
||||
e,
|
||||
"nearest",
|
||||
{ intersect: true },
|
||||
true
|
||||
if (!startTime) {
|
||||
// Calculate default start time based on dayDifference
|
||||
startTime = new Date(
|
||||
endTime.getTime() - dayDifference * 24 * 3600 * 1000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (points.length) {
|
||||
const firstPoint = points[0];
|
||||
const statisticId = this._statisticIds[firstPoint.datasetIndex];
|
||||
if (!isExternalStatistic(statisticId)) {
|
||||
fireEvent(this, "hass-more-info", { entityId: statisticId });
|
||||
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
|
||||
}
|
||||
}
|
||||
this._chartOptions = {
|
||||
xAxis: [
|
||||
{
|
||||
type: "time",
|
||||
min: startTime,
|
||||
max: endTime,
|
||||
},
|
||||
{
|
||||
type: "time",
|
||||
show: false,
|
||||
},
|
||||
],
|
||||
yAxis: {
|
||||
type: this.logarithmicScale ? "log" : "value",
|
||||
name: this.unit,
|
||||
nameGap: 2,
|
||||
nameTextStyle: {
|
||||
align: "left",
|
||||
},
|
||||
position: computeRTL(this.hass) ? "right" : "left",
|
||||
// @ts-ignore
|
||||
scale: true,
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
splitLine: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: !this.hideLegend,
|
||||
type: "scroll",
|
||||
animationDurationUpdate: 400,
|
||||
icon: "circle",
|
||||
padding: [20, 0],
|
||||
data: this._legendData,
|
||||
},
|
||||
grid: {
|
||||
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
|
||||
left: 1,
|
||||
right: 1,
|
||||
bottom: 0,
|
||||
containLabel: true,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
appendTo: document.body,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -325,8 +346,8 @@ export class StatisticsChart extends LitElement {
|
||||
|
||||
let colorIndex = 0;
|
||||
const statisticsData = Object.entries(this.statisticsData);
|
||||
const totalDataSets: ChartDataset<"line">[] = [];
|
||||
const totalDatasetExtras: ChartDatasetExtra[] = [];
|
||||
const totalDataSets: typeof this._chartData = [];
|
||||
const legendData: { name: string; color: string }[] = [];
|
||||
const statisticIds: string[] = [];
|
||||
let endTime: Date;
|
||||
|
||||
@@ -348,6 +369,7 @@ export class StatisticsChart extends LitElement {
|
||||
if (endTime > new Date()) {
|
||||
endTime = new Date();
|
||||
}
|
||||
this.endTime = endTime;
|
||||
|
||||
let unit: string | undefined | null;
|
||||
|
||||
@@ -372,19 +394,19 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
|
||||
// array containing [value1, value2, etc]
|
||||
let prevValues: (number | null)[] | null = null;
|
||||
let prevValues: (number | null)[][] | null = null;
|
||||
let prevEndTime: Date | undefined;
|
||||
|
||||
// The datasets for the current statistic
|
||||
const statDataSets: ChartDataset<"line">[] = [];
|
||||
const statDatasetExtras: ChartDatasetExtra[] = [];
|
||||
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
const statLegendData: { name: string; color: string }[] = [];
|
||||
|
||||
const pushData = (
|
||||
start: Date,
|
||||
end: Date,
|
||||
dataValues: (number | null)[] | null
|
||||
dataValues: (number | null)[][]
|
||||
) => {
|
||||
if (!dataValues) return;
|
||||
if (!dataValues.length) return;
|
||||
if (start > end) {
|
||||
// Drop data points that are after the requested endTime. This could happen if
|
||||
// endTime is "now" and client time is not in sync with server time.
|
||||
@@ -399,11 +421,12 @@ export class StatisticsChart extends LitElement {
|
||||
) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data.push({ x: prevEndTime.getTime(), y: prevValues[i]! });
|
||||
// @ts-expect-error
|
||||
d.data.push({ x: prevEndTime.getTime(), y: null });
|
||||
d.data!.push(
|
||||
this._transformDataValue([prevEndTime, ...prevValues[i]!])
|
||||
);
|
||||
d.data!.push([prevEndTime, null]);
|
||||
}
|
||||
d.data.push({ x: start.getTime(), y: dataValues[i]! });
|
||||
d.data!.push(this._transformDataValue([start, ...dataValues[i]!]));
|
||||
});
|
||||
prevValues = dataValues;
|
||||
prevEndTime = end;
|
||||
@@ -438,49 +461,64 @@ export class StatisticsChart extends LitElement {
|
||||
})
|
||||
: this.statTypes;
|
||||
|
||||
let displayed_legend = false;
|
||||
let displayedLegend = false;
|
||||
sortedTypes.forEach((type) => {
|
||||
if (statisticsHaveType(stats, type)) {
|
||||
const band = drawBands && (type === "min" || type === "max");
|
||||
if (!this.hideLegend) {
|
||||
const show_legend = hasMean
|
||||
const showLegend = hasMean
|
||||
? type === "mean"
|
||||
: displayed_legend === false;
|
||||
statDatasetExtras.push({
|
||||
legend_label: name,
|
||||
show_legend,
|
||||
});
|
||||
displayed_legend = displayed_legend || show_legend;
|
||||
: displayedLegend === false;
|
||||
if (showLegend) {
|
||||
statLegendData.push({ name, color });
|
||||
}
|
||||
displayedLegend = displayedLegend || showLegend;
|
||||
}
|
||||
statTypes.push(type);
|
||||
statDataSets.push({
|
||||
label: name
|
||||
const borderColor =
|
||||
band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color;
|
||||
const backgroundColor = band ? color + "3F" : color + "7F";
|
||||
const series: LineSeriesOption | BarSeriesOption = {
|
||||
id: `${statistic_id}-${type}`,
|
||||
type: this.chartType,
|
||||
smooth: this.chartType === "line" ? 0.4 : false,
|
||||
smoothMonotone: "x",
|
||||
cursor: "default",
|
||||
data: [],
|
||||
name: name
|
||||
? `${name} (${this.hass.localize(
|
||||
`ui.components.statistics_charts.statistic_types.${type}`
|
||||
)})
|
||||
`
|
||||
)})`
|
||||
: this.hass.localize(
|
||||
`ui.components.statistics_charts.statistic_types.${type}`
|
||||
),
|
||||
fill: drawBands
|
||||
? type === "min" && hasMean
|
||||
? "+1"
|
||||
: type === "max"
|
||||
? "-1"
|
||||
: false
|
||||
: false,
|
||||
borderColor:
|
||||
band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color,
|
||||
backgroundColor: band ? color + "3F" : color + "7F",
|
||||
pointRadius: 0,
|
||||
hidden: !this.hideLegend
|
||||
? this._hiddenStats.has(statistic_id)
|
||||
: false,
|
||||
data: [],
|
||||
// @ts-ignore
|
||||
unit: meta?.unit_of_measurement,
|
||||
band,
|
||||
});
|
||||
symbol: "circle",
|
||||
symbolSize: 0,
|
||||
animationDurationUpdate: 0,
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
},
|
||||
itemStyle:
|
||||
this.chartType === "bar"
|
||||
? {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
borderColor,
|
||||
borderWidth: 1.5,
|
||||
}
|
||||
: undefined,
|
||||
color: this.chartType === "bar" ? backgroundColor : borderColor,
|
||||
};
|
||||
if (band && this.chartType === "line") {
|
||||
series.stack = `band-${statistic_id}`;
|
||||
series.stackStrategy = "all";
|
||||
(series as LineSeriesOption).symbol = "none";
|
||||
if (drawBands && type === "max") {
|
||||
(series as LineSeriesOption).areaStyle = {
|
||||
color: color + "3F",
|
||||
};
|
||||
}
|
||||
}
|
||||
statDataSets.push(series);
|
||||
statisticIds.push(statistic_id);
|
||||
}
|
||||
});
|
||||
@@ -494,40 +532,79 @@ export class StatisticsChart extends LitElement {
|
||||
return;
|
||||
}
|
||||
prevDate = startDate;
|
||||
const dataValues: (number | null)[] = [];
|
||||
const dataValues: (number | null)[][] = [];
|
||||
statTypes.forEach((type) => {
|
||||
let val: number | null | undefined;
|
||||
const val: (number | null)[] = [];
|
||||
if (type === "sum") {
|
||||
if (firstSum === null || firstSum === undefined) {
|
||||
val = 0;
|
||||
val.push(0);
|
||||
firstSum = stat.sum;
|
||||
} else {
|
||||
val = (stat.sum || 0) - firstSum;
|
||||
val.push((stat.sum || 0) - firstSum);
|
||||
}
|
||||
} else if (type === "max" && this.chartType === "line") {
|
||||
const max = stat.max || 0;
|
||||
val.push(Math.abs(max - (stat.min || 0)));
|
||||
val.push(max);
|
||||
} else {
|
||||
val = stat[type];
|
||||
val.push(stat[type] ?? null);
|
||||
}
|
||||
dataValues.push(val ?? null);
|
||||
dataValues.push(val);
|
||||
});
|
||||
pushData(startDate, new Date(stat.end), dataValues);
|
||||
if (!this._hiddenStats.has(name)) {
|
||||
pushData(startDate, new Date(stat.end), dataValues);
|
||||
}
|
||||
});
|
||||
|
||||
// Concat two arrays
|
||||
Array.prototype.push.apply(totalDataSets, statDataSets);
|
||||
Array.prototype.push.apply(totalDatasetExtras, statDatasetExtras);
|
||||
Array.prototype.push.apply(legendData, statLegendData);
|
||||
});
|
||||
|
||||
if (unit) {
|
||||
this._createOptions(unit);
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
this._chartData = {
|
||||
datasets: totalDataSets,
|
||||
};
|
||||
this._chartDatasetExtra = totalDatasetExtras;
|
||||
legendData.forEach(({ name, color }) => {
|
||||
// Add an empty series for the legend
|
||||
totalDataSets.push({
|
||||
id: name + "-legend",
|
||||
name: name,
|
||||
color,
|
||||
type: this.chartType,
|
||||
data: [],
|
||||
xAxisIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
this._chartData = totalDataSets;
|
||||
if (legendData.length !== this._legendData.length) {
|
||||
// only update the legend if it has changed or it will trigger options update
|
||||
this._legendData = legendData.map(({ name }) => name);
|
||||
}
|
||||
this._statisticIds = statisticIds;
|
||||
}
|
||||
|
||||
private _transformDataValue(val: [Date, ...(number | null)[]]) {
|
||||
if (this.chartType === "bar" && val[1] && val[1] < 0) {
|
||||
return { value: val, itemStyle: { borderRadius: [0, 0, 4, 4] } };
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
private _clampYAxis(value?: number | ((values: any) => number)) {
|
||||
if (this.logarithmicScale) {
|
||||
// log(0) is -Infinity, so we need to set a minimum value
|
||||
if (typeof value === "number") {
|
||||
return Math.max(value, 0.1);
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
return (values: any) => Math.max(value(values), 0.1);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
|
@@ -1,22 +0,0 @@
|
||||
import type {
|
||||
BarControllerChartOptions,
|
||||
BarControllerDatasetOptions,
|
||||
} from "chart.js";
|
||||
|
||||
export interface TimeLineData {
|
||||
start: Date;
|
||||
end: Date;
|
||||
label?: string | null;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
declare module "chart.js" {
|
||||
interface ChartTypeRegistry {
|
||||
timeline: {
|
||||
chartOptions: BarControllerChartOptions;
|
||||
datasetOptions: BarControllerDatasetOptions;
|
||||
defaultDataPoint: TimeLineData;
|
||||
parsedDataType: any;
|
||||
};
|
||||
}
|
||||
}
|
@@ -1,63 +0,0 @@
|
||||
import type { BarOptions, BarProps } from "chart.js";
|
||||
import { BarElement } from "chart.js";
|
||||
import { hex2rgb } from "../../../common/color/convert-color";
|
||||
import { luminosity } from "../../../common/color/rgb";
|
||||
|
||||
export interface TextBarProps extends BarProps {
|
||||
text?: string | null;
|
||||
options?: Partial<TextBaroptions>;
|
||||
}
|
||||
|
||||
export interface TextBaroptions extends BarOptions {
|
||||
textPad?: number;
|
||||
textColor?: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
export class TextBarElement extends BarElement {
|
||||
static id = "textbar";
|
||||
|
||||
draw(ctx: CanvasRenderingContext2D) {
|
||||
super.draw(ctx);
|
||||
const options = this.options as TextBaroptions;
|
||||
const { x, y, base, width, text } = (
|
||||
this as BarElement<TextBarProps, TextBaroptions>
|
||||
).getProps(["x", "y", "base", "width", "text"]);
|
||||
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
const textRect = ctx.measureText(text);
|
||||
if (
|
||||
textRect.width === 0 ||
|
||||
textRect.width + (options.textPad || 4) + 2 > width
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const textColor =
|
||||
options.textColor ||
|
||||
(options?.backgroundColor === "transparent"
|
||||
? "transparent"
|
||||
: luminosity(hex2rgb(options.backgroundColor)) > 0.5
|
||||
? "#000"
|
||||
: "#fff");
|
||||
|
||||
// ctx.font = "12px arial";
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.lineWidth = 0;
|
||||
ctx.strokeStyle = textColor;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(
|
||||
text,
|
||||
x - width / 2 + (options.textPad || 4),
|
||||
y + (base - y) / 2
|
||||
);
|
||||
}
|
||||
|
||||
tooltipPosition(useFinalPosition: boolean) {
|
||||
const { x, y, base } = this.getProps(["x", "y", "base"], useFinalPosition);
|
||||
return { x, y: y + (base - y) / 2 };
|
||||
}
|
||||
}
|
@@ -1,255 +0,0 @@
|
||||
import type { BarElement } from "chart.js";
|
||||
import { BarController } from "chart.js";
|
||||
import type { TimeLineData } from "./const";
|
||||
import type { TextBarProps } from "./textbar-element";
|
||||
|
||||
function borderProps(properties) {
|
||||
let reverse;
|
||||
let start;
|
||||
let end;
|
||||
let top;
|
||||
let bottom;
|
||||
if (properties.horizontal) {
|
||||
reverse = properties.base > properties.x;
|
||||
start = "left";
|
||||
end = "right";
|
||||
} else {
|
||||
reverse = properties.base < properties.y;
|
||||
start = "bottom";
|
||||
end = "top";
|
||||
}
|
||||
if (reverse) {
|
||||
top = "end";
|
||||
bottom = "start";
|
||||
} else {
|
||||
top = "start";
|
||||
bottom = "end";
|
||||
}
|
||||
return { start, end, reverse, top, bottom };
|
||||
}
|
||||
|
||||
function setBorderSkipped(properties, options, stack, index) {
|
||||
let edge = options.borderSkipped;
|
||||
const res = {};
|
||||
|
||||
if (!edge) {
|
||||
properties.borderSkipped = res;
|
||||
return;
|
||||
}
|
||||
|
||||
if (edge === true) {
|
||||
properties.borderSkipped = {
|
||||
top: true,
|
||||
right: true,
|
||||
bottom: true,
|
||||
left: true,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const { start, end, reverse, top, bottom } = borderProps(properties);
|
||||
|
||||
if (edge === "middle" && stack) {
|
||||
properties.enableBorderRadius = true;
|
||||
if ((stack._top || 0) === index) {
|
||||
edge = top;
|
||||
} else if ((stack._bottom || 0) === index) {
|
||||
edge = bottom;
|
||||
} else {
|
||||
res[parseEdge(bottom, start, end, reverse)] = true;
|
||||
edge = top;
|
||||
}
|
||||
}
|
||||
|
||||
res[parseEdge(edge, start, end, reverse)] = true;
|
||||
properties.borderSkipped = res;
|
||||
}
|
||||
|
||||
function parseEdge(edge, a, b, reverse) {
|
||||
if (reverse) {
|
||||
edge = swap(edge, a, b);
|
||||
edge = startEnd(edge, b, a);
|
||||
} else {
|
||||
edge = startEnd(edge, a, b);
|
||||
}
|
||||
return edge;
|
||||
}
|
||||
|
||||
function swap(orig, v1, v2) {
|
||||
return orig === v1 ? v2 : orig === v2 ? v1 : orig;
|
||||
}
|
||||
|
||||
function startEnd(v, start, end) {
|
||||
return v === "start" ? start : v === "end" ? end : v;
|
||||
}
|
||||
|
||||
function setInflateAmount(
|
||||
properties,
|
||||
{ inflateAmount }: { inflateAmount?: string | number },
|
||||
ratio
|
||||
) {
|
||||
properties.inflateAmount =
|
||||
inflateAmount === "auto" ? (ratio === 1 ? 0.33 : 0) : inflateAmount;
|
||||
}
|
||||
|
||||
function parseValue(entry, item, vScale, i) {
|
||||
const startValue = vScale.parse(entry.start, i);
|
||||
const endValue = vScale.parse(entry.end, i);
|
||||
const min = Math.min(startValue, endValue);
|
||||
const max = Math.max(startValue, endValue);
|
||||
let barStart = min;
|
||||
let barEnd = max;
|
||||
|
||||
if (Math.abs(min) > Math.abs(max)) {
|
||||
barStart = max;
|
||||
barEnd = min;
|
||||
}
|
||||
|
||||
// Store `barEnd` (furthest away from origin) as parsed value,
|
||||
// to make stacking straight forward
|
||||
item[vScale.axis] = barEnd;
|
||||
|
||||
item._custom = {
|
||||
barStart,
|
||||
barEnd,
|
||||
start: startValue,
|
||||
end: endValue,
|
||||
min,
|
||||
max,
|
||||
};
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export class TimelineController extends BarController {
|
||||
static id = "timeline";
|
||||
|
||||
static defaults = {
|
||||
dataElementType: "textbar",
|
||||
dataElementOptions: ["text", "textColor", "textPadding"],
|
||||
elements: {
|
||||
showText: true,
|
||||
textPadding: 4,
|
||||
minBarWidth: 1,
|
||||
},
|
||||
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
static overrides = {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
parseObjectData(meta, data, start, count) {
|
||||
const iScale = meta.iScale;
|
||||
const vScale = meta.vScale;
|
||||
const labels = iScale.getLabels();
|
||||
const singleScale = iScale === vScale;
|
||||
const parsed: any[] = [];
|
||||
let i;
|
||||
let ilen;
|
||||
let item;
|
||||
let entry;
|
||||
|
||||
for (i = start, ilen = start + count; i < ilen; ++i) {
|
||||
entry = data[i];
|
||||
item = {};
|
||||
item[iScale.axis] = singleScale || iScale.parse(labels[i], i);
|
||||
parsed.push(parseValue(entry, item, vScale, i));
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
getLabelAndValue(index) {
|
||||
const meta = this._cachedMeta;
|
||||
const { vScale } = meta;
|
||||
const data = this.getDataset().data[index] as TimeLineData;
|
||||
|
||||
return {
|
||||
label: vScale!.getLabelForValue(this.index) || "",
|
||||
value: data.label || "",
|
||||
};
|
||||
}
|
||||
|
||||
updateElements(
|
||||
bars: BarElement[],
|
||||
start: number,
|
||||
count: number,
|
||||
mode: "reset" | "resize" | "none" | "hide" | "show" | "default" | "active"
|
||||
) {
|
||||
const vScale = this._cachedMeta.vScale!;
|
||||
const iScale = this._cachedMeta.iScale!;
|
||||
const dataset = this.getDataset();
|
||||
|
||||
const firstOpts = this.resolveDataElementOptions(start, mode);
|
||||
const sharedOptions = this.getSharedOptions(firstOpts);
|
||||
const includeOptions = this.includeOptions(mode, sharedOptions!);
|
||||
|
||||
const horizontal = vScale.isHorizontal();
|
||||
|
||||
this.updateSharedOptions(sharedOptions!, mode, firstOpts);
|
||||
|
||||
for (let index = start; index < start + count; index++) {
|
||||
const data = dataset.data[index] as TimeLineData;
|
||||
|
||||
const y = vScale.getPixelForValue(this.index);
|
||||
|
||||
const xStart = iScale.getPixelForValue(
|
||||
Math.max(iScale.min, data.start.getTime())
|
||||
);
|
||||
const xEnd = iScale.getPixelForValue(data.end.getTime());
|
||||
const width = xEnd - xStart;
|
||||
|
||||
const parsed = this.getParsed(index);
|
||||
const stack = (parsed._stacks || {})[vScale.axis];
|
||||
|
||||
const height = 10;
|
||||
|
||||
const properties: TextBarProps = {
|
||||
horizontal,
|
||||
x: xStart + width / 2, // Center of the bar
|
||||
y: y - height, // Top of bar
|
||||
width,
|
||||
height: 0,
|
||||
base: y + height, // Bottom of bar,
|
||||
// Text
|
||||
text: data.label,
|
||||
};
|
||||
|
||||
if (includeOptions) {
|
||||
properties.options =
|
||||
sharedOptions || this.resolveDataElementOptions(index, mode);
|
||||
|
||||
properties.options = {
|
||||
...properties.options,
|
||||
backgroundColor: data.color,
|
||||
};
|
||||
}
|
||||
const options = properties.options || bars[index].options;
|
||||
|
||||
setBorderSkipped(properties, options, stack, index);
|
||||
setInflateAmount(properties, options, 1);
|
||||
this.updateElement(bars[index], index, properties as any, mode);
|
||||
}
|
||||
}
|
||||
|
||||
removeHoverStyle(_element, _datasetIndex, _index) {
|
||||
// this._setStyle(element, index, 'active', false);
|
||||
}
|
||||
|
||||
setHoverStyle(_element, _datasetIndex, _index) {
|
||||
// this._setStyle(element, index, 'active', true);
|
||||
}
|
||||
}
|
@@ -1,11 +1,11 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { getGraphColorByIndex } from "../../../common/color/colors";
|
||||
import { hex2rgb, lab2hex, rgb2lab } from "../../../common/color/convert-color";
|
||||
import { labBrighten } from "../../../common/color/lab";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { stateColorProperties } from "../../../common/entity/state_color";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity";
|
||||
import { computeCssValue } from "../../../resources/css-variables";
|
||||
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||
import { hex2rgb, lab2hex, rgb2lab } from "../../common/color/convert-color";
|
||||
import { labBrighten } from "../../common/color/lab";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { stateColorProperties } from "../../common/entity/state_color";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity";
|
||||
import { computeCssValue } from "../../resources/css-variables";
|
||||
|
||||
const DOMAIN_STATE_SHADES: Record<string, Record<string, number>> = {
|
||||
media_player: {
|
@@ -33,9 +33,10 @@ export class HaAssistChip extends MdAssistChip {
|
||||
}
|
||||
/** Set the size of mdc icons **/
|
||||
::slotted([slot="icon"]),
|
||||
::slotted([slot="trailingIcon"]) {
|
||||
::slotted([slot="trailing-icon"]) {
|
||||
display: flex;
|
||||
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
|
||||
font-size: var(--_label-text-size) !important;
|
||||
}
|
||||
|
||||
.trailing.icon ::slotted(*),
|
||||
|
@@ -178,7 +178,7 @@ class HaEntityStatePicker extends LitElement {
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
filter="button.trailing.action"
|
||||
handle-selector="button.primary.action"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
@@ -195,12 +195,7 @@ class HaEntityStatePicker extends LitElement {
|
||||
.label=${label}
|
||||
selected
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDrag}
|
||||
data-handle
|
||||
></ha-svg-icon>
|
||||
|
||||
<ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon>
|
||||
${label}
|
||||
</ha-input-chip>
|
||||
`;
|
||||
|
@@ -276,6 +276,8 @@ export class HaAreaPicker extends LitElement {
|
||||
icon: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
@@ -294,6 +296,8 @@ export class HaAreaPicker extends LitElement {
|
||||
icon: "mdi:plus",
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
@@ -378,6 +382,8 @@ export class HaAreaPicker extends LitElement {
|
||||
picture: null,
|
||||
labels: [],
|
||||
aliases: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
@@ -396,6 +402,8 @@ export class HaAreaPicker extends LitElement {
|
||||
picture: null,
|
||||
labels: [],
|
||||
aliases: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
|
@@ -532,7 +532,7 @@ export class HaAssistChat extends LitElement {
|
||||
float: var(--float-end);
|
||||
text-align: right;
|
||||
border-bottom-right-radius: 0px;
|
||||
background-color: var(--primary-color);
|
||||
background-color: var(--chat-background-color-user, var(--primary-color));
|
||||
color: var(--text-primary-color);
|
||||
direction: var(--direction);
|
||||
}
|
||||
@@ -543,7 +543,10 @@ export class HaAssistChat extends LitElement {
|
||||
margin-inline-start: initial;
|
||||
float: var(--float-start);
|
||||
border-bottom-left-radius: 0px;
|
||||
background-color: var(--secondary-background-color);
|
||||
background-color: var(
|
||||
--chat-background-color-hass,
|
||||
var(--secondary-background-color)
|
||||
);
|
||||
|
||||
color: var(--primary-text-color);
|
||||
direction: var(--direction);
|
||||
|
@@ -329,14 +329,12 @@ export class HaBaseTimeInput extends LitElement {
|
||||
:host([clearable]) {
|
||||
position: relative;
|
||||
}
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.time-input-wrap-wrap {
|
||||
display: flex;
|
||||
}
|
||||
.time-input-wrap {
|
||||
display: flex;
|
||||
flex: var(--time-input-flex, unset);
|
||||
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
@@ -345,6 +343,7 @@ export class HaBaseTimeInput extends LitElement {
|
||||
}
|
||||
ha-textfield {
|
||||
width: 55px;
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
--mdc-shape-small: 0;
|
||||
--text-field-appearance: none;
|
||||
|
@@ -23,6 +23,9 @@ export class HaButton extends Button {
|
||||
.slot-container {
|
||||
overflow: var(--button-slot-container-overflow, visible);
|
||||
}
|
||||
:host([destructive]) {
|
||||
--mdc-theme-primary: var(--error-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@@ -9,12 +9,13 @@ import {
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
endOfYear,
|
||||
isThisYear,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
isThisYear,
|
||||
} from "date-fns";
|
||||
import { fromZonedTime, toZonedTime } from "date-fns-tz";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -22,16 +23,18 @@ import { ifDefined } from "lit/directives/if-defined";
|
||||
import { calcDate, shiftDateRange } from "../common/datetime/calc_date";
|
||||
import { firstWeekdayIndex } from "../common/datetime/first_weekday";
|
||||
import {
|
||||
formatShortDateTimeWithYear,
|
||||
formatShortDateTime,
|
||||
formatShortDateTimeWithYear,
|
||||
} from "../common/datetime/format_date_time";
|
||||
import { useAmPm } from "../common/datetime/use_am_pm";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { TimeZone } from "../data/translation";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./date-range-picker";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-textarea";
|
||||
import "./ha-icon-button-next";
|
||||
import "./ha-icon-button-prev";
|
||||
import "./ha-textarea";
|
||||
|
||||
export type DateRangePickerRanges = Record<string, [Date, Date]>;
|
||||
|
||||
@@ -51,7 +54,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
public autoApply = false;
|
||||
|
||||
@property({ attribute: "time-picker", type: Boolean })
|
||||
public timePicker = true;
|
||||
public timePicker = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@@ -197,14 +200,15 @@ export class HaDateRangePicker extends LitElement {
|
||||
?auto-apply=${this.autoApply}
|
||||
time-picker=${this.timePicker}
|
||||
twentyfour-hours=${this._hour24format}
|
||||
start-date=${this.startDate.toISOString()}
|
||||
end-date=${this.endDate.toISOString()}
|
||||
start-date=${this._formatDate(this.startDate)}
|
||||
end-date=${this._formatDate(this.endDate)}
|
||||
?ranges=${this.ranges !== false}
|
||||
opening-direction=${ifDefined(
|
||||
this.openingDirection || this._calcedOpeningDirection
|
||||
)}
|
||||
first-day=${firstWeekdayIndex(this.hass.locale)}
|
||||
language=${this.hass.locale.language}
|
||||
@change=${this._handleChange}
|
||||
>
|
||||
<div slot="input" class="date-range-inputs" @click=${this._handleClick}>
|
||||
${!this.minimal
|
||||
@@ -325,9 +329,31 @@ export class HaDateRangePicker extends LitElement {
|
||||
}
|
||||
|
||||
private _applyDateRange() {
|
||||
if (this.hass.locale.time_zone === TimeZone.server) {
|
||||
const dateRangePicker = this._dateRangePicker;
|
||||
|
||||
const startDate = fromZonedTime(
|
||||
dateRangePicker.start,
|
||||
this.hass.config.time_zone
|
||||
);
|
||||
const endDate = fromZonedTime(
|
||||
dateRangePicker.end,
|
||||
this.hass.config.time_zone
|
||||
);
|
||||
|
||||
dateRangePicker.clickRange([startDate, endDate]);
|
||||
}
|
||||
|
||||
this._dateRangePicker.clickedApply();
|
||||
}
|
||||
|
||||
private _formatDate(date: Date): string {
|
||||
if (this.hass.locale.time_zone === TimeZone.server) {
|
||||
return toZonedTime(date, this.hass.config.time_zone).toISOString();
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
private get _dateRangePicker() {
|
||||
const dateRangePicker = this.shadowRoot!.querySelector(
|
||||
"date-range-picker"
|
||||
@@ -358,6 +384,16 @@ export class HaDateRangePicker extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleChange(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const startDate = ev.detail.startDate;
|
||||
const endDate = ev.detail.endDate;
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { startDate, endDate },
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
ha-icon-button {
|
||||
|
@@ -79,6 +79,7 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
.disabled=${this.disabled}
|
||||
@opening=${this._handleOpen}
|
||||
@closing=${this._handleClose}
|
||||
positioning="fixed"
|
||||
>
|
||||
<ha-textfield
|
||||
slot="trigger"
|
||||
|
@@ -17,6 +17,7 @@ export class HaMdListItem extends MdListItem {
|
||||
}
|
||||
md-item {
|
||||
overflow: var(--md-item-overflow, hidden);
|
||||
align-items: var(--md-item-align-items, center);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { mdiDeleteOutline, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { haStyle } from "../resources/styles";
|
||||
@@ -8,6 +8,7 @@ import type { HomeAssistant } from "../types";
|
||||
import "./ha-button";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-textfield";
|
||||
import "./ha-input-helper-text";
|
||||
import type { HaTextField } from "./ha-textfield";
|
||||
|
||||
@customElement("ha-multi-textfield")
|
||||
@@ -20,6 +21,8 @@ class HaMultiTextField extends LitElement {
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property({ attribute: false }) public helper?: string;
|
||||
|
||||
@property({ attribute: false }) public inputType?: string;
|
||||
|
||||
@property({ attribute: false }) public inputSuffix?: string;
|
||||
@@ -69,12 +72,21 @@ class HaMultiTextField extends LitElement {
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<div class="layout horizontal center-center">
|
||||
<div class="layout horizontal">
|
||||
<ha-button @click=${this._addItem} .disabled=${this.disabled}>
|
||||
${this.addLabel ?? this.hass?.localize("ui.common.add") ?? "Add"}
|
||||
${this.addLabel ??
|
||||
(this.label
|
||||
? this.hass?.localize("ui.components.multi-textfield.add_item", {
|
||||
item: this.label,
|
||||
})
|
||||
: this.hass?.localize("ui.common.add")) ??
|
||||
"Add"}
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-button>
|
||||
</div>
|
||||
${this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
|
@@ -156,6 +156,7 @@ export class HaSelectSelector extends LitElement {
|
||||
no-style
|
||||
.disabled=${!this.selector.select.reorder}
|
||||
@item-moved=${this._itemMoved}
|
||||
handle-selector="button.primary.action"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
@@ -177,7 +178,6 @@ export class HaSelectSelector extends LitElement {
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDrag}
|
||||
data-handle
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing}
|
||||
|
@@ -50,6 +50,7 @@ export class HaTextSelector extends LitElement {
|
||||
.inputType=${this.selector.text?.type}
|
||||
.inputSuffix=${this.selector.text?.suffix}
|
||||
.inputPrefix=${this.selector.text?.prefix}
|
||||
.helper=${this.helper}
|
||||
.autocomplete=${this.selector.text?.autocomplete}
|
||||
@value-changed=${this._handleChange}
|
||||
>
|
||||
|
@@ -1115,6 +1115,8 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
.child .play:not(.can_expand) {
|
||||
--mdc-icon-button-size: 70px;
|
||||
--mdc-icon-size: 48px;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
ha-card:hover .image {
|
||||
@@ -1126,10 +1128,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
ha-card:hover .play:not(.can_expand) {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
ha-card:hover .play.can_expand {
|
||||
bottom: 8px;
|
||||
}
|
||||
@@ -1144,10 +1142,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
opacity 0.1s ease-out;
|
||||
}
|
||||
|
||||
.child .play:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.child .title {
|
||||
font-size: 16px;
|
||||
padding-top: 16px;
|
||||
@@ -1331,11 +1325,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
ha-browse-media-tts {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
ha-card:hover .play:not(.can_expand) {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { css, html, LitElement, svg } from "lit";
|
||||
import { css, html, LitElement, nothing, svg } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { BRANCH_HEIGHT, SPACING } from "./hat-graph-const";
|
||||
@@ -41,8 +41,8 @@ export class HatGraphBranch extends LitElement {
|
||||
branches.push({
|
||||
x: width / 2 + total_width,
|
||||
height,
|
||||
start: c.hasAttribute("graphStart"),
|
||||
end: c.hasAttribute("graphEnd"),
|
||||
start: c.hasAttribute("graph-start"),
|
||||
end: c.hasAttribute("graph-end"),
|
||||
track: c.hasAttribute("track"),
|
||||
});
|
||||
total_width += width;
|
||||
@@ -65,11 +65,8 @@ export class HatGraphBranch extends LitElement {
|
||||
return html`
|
||||
<slot name="head"></slot>
|
||||
${!this.start
|
||||
? svg`
|
||||
<svg
|
||||
id="top"
|
||||
width="${this._totalWidth}"
|
||||
>
|
||||
? html`
|
||||
<svg id="top" width=${this._totalWidth}>
|
||||
${this._branches.map((branch) =>
|
||||
branch.start
|
||||
? ""
|
||||
@@ -86,7 +83,7 @@ export class HatGraphBranch extends LitElement {
|
||||
)}
|
||||
</svg>
|
||||
`
|
||||
: ""}
|
||||
: nothing}
|
||||
<div id="branches">
|
||||
<svg id="lines" width=${this._totalWidth} height=${this._maxHeight}>
|
||||
${this._branches.map((branch) => {
|
||||
@@ -107,11 +104,8 @@ export class HatGraphBranch extends LitElement {
|
||||
</div>
|
||||
|
||||
${!this.short
|
||||
? svg`
|
||||
<svg
|
||||
id="bottom"
|
||||
width="${this._totalWidth}"
|
||||
>
|
||||
? html`
|
||||
<svg id="bottom" width=${this._totalWidth}>
|
||||
${this._branches.map((branch) => {
|
||||
if (branch.end) return "";
|
||||
return svg`
|
||||
@@ -128,7 +122,7 @@ export class HatGraphBranch extends LitElement {
|
||||
})}
|
||||
</svg>
|
||||
`
|
||||
: ""}
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
|
@@ -7,13 +7,15 @@ import type { RegistryEntry } from "./registry";
|
||||
export { subscribeAreaRegistry } from "./ws-area_registry";
|
||||
|
||||
export interface AreaRegistryEntry extends RegistryEntry {
|
||||
aliases: string[];
|
||||
area_id: string;
|
||||
floor_id: string | null;
|
||||
name: string;
|
||||
picture: string | null;
|
||||
humidity_entity_id: string | null;
|
||||
icon: string | null;
|
||||
labels: string[];
|
||||
aliases: string[];
|
||||
name: string;
|
||||
picture: string | null;
|
||||
temperature_entity_id: string | null;
|
||||
}
|
||||
|
||||
export type AreaEntityLookup = Record<string, EntityRegistryEntry[]>;
|
||||
@@ -21,12 +23,14 @@ export type AreaEntityLookup = Record<string, EntityRegistryEntry[]>;
|
||||
export type AreaDeviceLookup = Record<string, DeviceRegistryEntry[]>;
|
||||
|
||||
export interface AreaRegistryEntryMutableParams {
|
||||
name: string;
|
||||
floor_id?: string | null;
|
||||
picture?: string | null;
|
||||
icon?: string | null;
|
||||
aliases?: string[];
|
||||
floor_id?: string | null;
|
||||
humidity_entity_id?: string | null;
|
||||
icon?: string | null;
|
||||
labels?: string[];
|
||||
name: string;
|
||||
picture?: string | null;
|
||||
temperature_entity_id?: string | null;
|
||||
}
|
||||
|
||||
export const createAreaRegistryEntry = (
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import { memoize } from "@fullcalendar/core/internal";
|
||||
import { setHours, setMinutes } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
import checkValidDate from "../common/datetime/check_valid_date";
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDateTimeNumeric,
|
||||
@@ -12,21 +14,32 @@ import { fileDownload } from "../util/file_download";
|
||||
import { domainToName } from "./integration";
|
||||
import type { FrontendLocaleData } from "./translation";
|
||||
|
||||
export const enum BackupScheduleState {
|
||||
export const enum BackupScheduleRecurrence {
|
||||
NEVER = "never",
|
||||
DAILY = "daily",
|
||||
MONDAY = "mon",
|
||||
TUESDAY = "tue",
|
||||
WEDNESDAY = "wed",
|
||||
THURSDAY = "thu",
|
||||
FRIDAY = "fri",
|
||||
SATURDAY = "sat",
|
||||
SUNDAY = "sun",
|
||||
CUSTOM_DAYS = "custom_days",
|
||||
}
|
||||
|
||||
export type BackupDay = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
|
||||
|
||||
export const BACKUP_DAYS: BackupDay[] = [
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
"sun",
|
||||
];
|
||||
|
||||
export const sortWeekdays = (weekdays) =>
|
||||
weekdays.sort((a, b) => BACKUP_DAYS.indexOf(a) - BACKUP_DAYS.indexOf(b));
|
||||
|
||||
export interface BackupConfig {
|
||||
last_attempted_automatic_backup: string | null;
|
||||
last_completed_automatic_backup: string | null;
|
||||
next_automatic_backup: string | null;
|
||||
next_automatic_backup_additional?: boolean;
|
||||
create_backup: {
|
||||
agent_ids: string[];
|
||||
include_addons: string[] | null;
|
||||
@@ -41,8 +54,11 @@ export interface BackupConfig {
|
||||
days?: number | null;
|
||||
};
|
||||
schedule: {
|
||||
state: BackupScheduleState;
|
||||
recurrence: BackupScheduleRecurrence;
|
||||
time?: string | null;
|
||||
days: BackupDay[];
|
||||
};
|
||||
agents: BackupAgentsConfig;
|
||||
}
|
||||
|
||||
export interface BackupMutableConfig {
|
||||
@@ -59,21 +75,39 @@ export interface BackupMutableConfig {
|
||||
copies?: number | null;
|
||||
days?: number | null;
|
||||
};
|
||||
schedule?: BackupScheduleState;
|
||||
schedule?: {
|
||||
recurrence: BackupScheduleRecurrence;
|
||||
time?: string | null;
|
||||
days?: BackupDay[] | null;
|
||||
};
|
||||
agents?: BackupAgentsConfig;
|
||||
}
|
||||
|
||||
export type BackupAgentsConfig = Record<string, BackupAgentConfig>;
|
||||
|
||||
export interface BackupAgentConfig {
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
export interface BackupAgent {
|
||||
agent_id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BackupContentAgent {
|
||||
size: number;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
export interface BackupContent {
|
||||
backup_id: string;
|
||||
date: string;
|
||||
name: string;
|
||||
protected: boolean;
|
||||
size: number;
|
||||
agent_ids?: string[];
|
||||
agents: Record<string, BackupContentAgent>;
|
||||
failed_agent_ids?: string[];
|
||||
extra_metadata?: {
|
||||
"supervisor.addon_update"?: string;
|
||||
};
|
||||
with_automatic_settings: boolean;
|
||||
}
|
||||
|
||||
@@ -135,8 +169,12 @@ export const updateBackupConfig = (
|
||||
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 getBackupDownloadUrl = (
|
||||
id: string,
|
||||
agentId: string,
|
||||
password?: string | null
|
||||
) =>
|
||||
`/api/backup/download/${id}?agent_id=${agentId}${password ? `&password=${password}` : ""}`;
|
||||
|
||||
export const fetchBackupInfo = (hass: HomeAssistant): Promise<BackupInfo> =>
|
||||
hass.callWS({
|
||||
@@ -229,6 +267,19 @@ export const getPreferredAgentForDownload = (agents: string[]) => {
|
||||
return agents[0];
|
||||
};
|
||||
|
||||
export const canDecryptBackupOnDownload = (
|
||||
hass: HomeAssistant,
|
||||
backup_id: string,
|
||||
agent_id: string,
|
||||
password: string
|
||||
) =>
|
||||
hass.callWS({
|
||||
type: "backup/can_decrypt_on_download",
|
||||
backup_id,
|
||||
agent_id,
|
||||
password,
|
||||
});
|
||||
|
||||
export const CORE_LOCAL_AGENT = "backup.local";
|
||||
export const HASSIO_LOCAL_AGENT = "hassio.local";
|
||||
export const CLOUD_AGENT = "cloud.cloud";
|
||||
@@ -244,13 +295,18 @@ export const isNetworkMountAgent = (agentId: string) => {
|
||||
export const computeBackupAgentName = (
|
||||
localize: LocalizeFunc,
|
||||
agentId: string,
|
||||
agentIds?: string[]
|
||||
agents: BackupAgent[]
|
||||
) => {
|
||||
if (isLocalAgent(agentId)) {
|
||||
return localize("ui.panel.config.backup.agents.local_agent");
|
||||
}
|
||||
const [domain, name] = agentId.split(".");
|
||||
|
||||
const agent = agents.find((a) => a.agent_id === agentId);
|
||||
|
||||
const domain = agentId.split(".")[0];
|
||||
const name = agent ? agent.name : agentId.split(".")[1];
|
||||
|
||||
// If it's a network mount agent, only show the name
|
||||
if (isNetworkMountAgent(agentId)) {
|
||||
return name;
|
||||
}
|
||||
@@ -258,13 +314,38 @@ export const computeBackupAgentName = (
|
||||
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;
|
||||
const showName =
|
||||
agents.filter((a) => a.agent_id.split(".")[0] === domain).length > 1;
|
||||
|
||||
return showName ? `${domainName}: ${name}` : domainName;
|
||||
};
|
||||
|
||||
export const computeBackupSize = (backup: BackupContent) =>
|
||||
Math.max(...Object.values(backup.agents).map((agent) => agent.size));
|
||||
|
||||
export type BackupType = "automatic" | "manual" | "addon_update";
|
||||
|
||||
const BACKUP_TYPE_ORDER: BackupType[] = ["automatic", "manual", "addon_update"];
|
||||
|
||||
export const getBackupTypes = memoize((isHassio: boolean) =>
|
||||
isHassio
|
||||
? BACKUP_TYPE_ORDER
|
||||
: BACKUP_TYPE_ORDER.filter((type) => type !== "addon_update")
|
||||
);
|
||||
|
||||
export const computeBackupType = (
|
||||
backup: BackupContent,
|
||||
isHassio: boolean
|
||||
): BackupType => {
|
||||
if (backup.with_automatic_settings) {
|
||||
return "automatic";
|
||||
}
|
||||
if (isHassio && backup.extra_metadata?.["supervisor.addon_update"] != null) {
|
||||
return "addon_update";
|
||||
}
|
||||
return "manual";
|
||||
};
|
||||
|
||||
export const compareAgents = (a: string, b: string) => {
|
||||
const isLocalA = isLocalAgent(a);
|
||||
const isLocalB = isLocalAgent(b);
|
||||
@@ -337,9 +418,34 @@ export const downloadEmergencyKit = (
|
||||
geneateEmergencyKitFileName(hass, appendFileName)
|
||||
);
|
||||
|
||||
export const DEFAULT_OPTIMIZED_BACKUP_START_TIME = setMinutes(
|
||||
setHours(new Date(), 4),
|
||||
45
|
||||
);
|
||||
|
||||
export const DEFAULT_OPTIMIZED_BACKUP_END_TIME = setMinutes(
|
||||
setHours(new Date(), 5),
|
||||
45
|
||||
);
|
||||
|
||||
export const getFormattedBackupTime = memoizeOne(
|
||||
(locale: FrontendLocaleData, config: HassConfig) => {
|
||||
const date = setMinutes(setHours(new Date(), 4), 45);
|
||||
return formatTime(date, locale, config);
|
||||
(
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig,
|
||||
backupTime?: Date | string | null
|
||||
) => {
|
||||
if (checkValidDate(backupTime as Date)) {
|
||||
return formatTime(backupTime as Date, locale, config);
|
||||
}
|
||||
if (typeof backupTime === "string" && backupTime) {
|
||||
const splitted = backupTime.split(":");
|
||||
const date = setMinutes(
|
||||
setHours(new Date(), parseInt(splitted[0])),
|
||||
parseInt(splitted[1])
|
||||
);
|
||||
return formatTime(date, locale, config);
|
||||
}
|
||||
|
||||
return `${formatTime(DEFAULT_OPTIMIZED_BACKUP_START_TIME, locale, config)} - ${formatTime(DEFAULT_OPTIMIZED_BACKUP_END_TIME, locale, config)}`;
|
||||
}
|
||||
);
|
||||
|
167
src/data/bluetooth.ts
Normal file
167
src/data/bluetooth.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
createCollection,
|
||||
type Connection,
|
||||
type UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { Store } from "home-assistant-js-websocket/dist/store";
|
||||
import type { DataTableRowData } from "../components/data-table/ha-data-table";
|
||||
|
||||
export interface BluetoothDeviceData extends DataTableRowData {
|
||||
address: string;
|
||||
connectable: boolean;
|
||||
manufacturer_data: Record<number, string>;
|
||||
name: string;
|
||||
rssi: number;
|
||||
service_data: Record<string, string>;
|
||||
service_uuids: string[];
|
||||
source: string;
|
||||
time: number;
|
||||
tx_power: number;
|
||||
}
|
||||
|
||||
export interface BluetoothScannerDetails {
|
||||
source: string;
|
||||
connectable: boolean;
|
||||
name: string;
|
||||
adapter: string;
|
||||
}
|
||||
|
||||
export type BluetoothScannersDetails = Record<string, BluetoothScannerDetails>;
|
||||
|
||||
interface BluetoothRemoveDeviceData {
|
||||
address: string;
|
||||
}
|
||||
|
||||
interface BluetoothAdvertisementSubscriptionMessage {
|
||||
add?: BluetoothDeviceData[];
|
||||
change?: BluetoothDeviceData[];
|
||||
remove?: BluetoothRemoveDeviceData[];
|
||||
}
|
||||
|
||||
interface BluetoothScannersDetailsSubscriptionMessage {
|
||||
add?: BluetoothScannerDetails[];
|
||||
remove?: BluetoothScannerDetails[];
|
||||
}
|
||||
|
||||
export interface BluetoothAllocationsData {
|
||||
source: string;
|
||||
slots: number;
|
||||
free: number;
|
||||
allocated: string[];
|
||||
}
|
||||
|
||||
export const subscribeBluetoothScannersDetailsUpdates = (
|
||||
conn: Connection,
|
||||
store: Store<BluetoothScannersDetails>
|
||||
): Promise<UnsubscribeFunc> =>
|
||||
conn.subscribeMessage<BluetoothScannersDetailsSubscriptionMessage>(
|
||||
(event) => {
|
||||
const data = { ...(store.state || {}) };
|
||||
if (event.add) {
|
||||
for (const device_data of event.add) {
|
||||
data[device_data.source] = device_data;
|
||||
}
|
||||
}
|
||||
if (event.remove) {
|
||||
for (const device_data of event.remove) {
|
||||
delete data[device_data.source];
|
||||
}
|
||||
}
|
||||
store.setState(data, true);
|
||||
},
|
||||
{
|
||||
type: `bluetooth/subscribe_scanner_details`,
|
||||
}
|
||||
);
|
||||
|
||||
export const subscribeBluetoothScannersDetails = (
|
||||
conn: Connection,
|
||||
callbackFunction: (bluetoothScannersDetails: BluetoothScannersDetails) => void
|
||||
) =>
|
||||
createCollection<BluetoothScannersDetails>(
|
||||
"_bluetoothScannerDetails",
|
||||
() => Promise.resolve<BluetoothScannersDetails>({}), // empty hash as initial state
|
||||
|
||||
subscribeBluetoothScannersDetailsUpdates,
|
||||
conn,
|
||||
callbackFunction
|
||||
);
|
||||
|
||||
const subscribeBluetoothAdvertisementsUpdates = (
|
||||
conn: Connection,
|
||||
store: Store<BluetoothDeviceData[]>
|
||||
): Promise<UnsubscribeFunc> =>
|
||||
conn.subscribeMessage<BluetoothAdvertisementSubscriptionMessage>(
|
||||
(event) => {
|
||||
const data = [...(store.state || [])];
|
||||
if (event.add) {
|
||||
for (const device_data of event.add) {
|
||||
const index = data.findIndex(
|
||||
(d) => d.address === device_data.address
|
||||
);
|
||||
if (index === -1) {
|
||||
data.push(device_data);
|
||||
} else {
|
||||
data[index] = device_data;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.change) {
|
||||
for (const device_data of event.change) {
|
||||
const index = data.findIndex(
|
||||
(d) => d.address === device_data.address
|
||||
);
|
||||
if (index !== -1) {
|
||||
data[index] = device_data;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.remove) {
|
||||
for (const device_data of event.remove) {
|
||||
const index = data.findIndex(
|
||||
(d) => d.address === device_data.address
|
||||
);
|
||||
if (index !== -1) {
|
||||
data.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
store.setState(data, true);
|
||||
},
|
||||
{
|
||||
type: `bluetooth/subscribe_advertisements`,
|
||||
}
|
||||
);
|
||||
|
||||
export const subscribeBluetoothAdvertisements = (
|
||||
conn: Connection,
|
||||
callbackFunction: (bluetoothDeviceData: BluetoothDeviceData[]) => void
|
||||
) =>
|
||||
createCollection<BluetoothDeviceData[]>(
|
||||
"_bluetoothDeviceRows",
|
||||
() => Promise.resolve<BluetoothDeviceData[]>([]), // empty array as initial state
|
||||
|
||||
subscribeBluetoothAdvertisementsUpdates,
|
||||
conn,
|
||||
callbackFunction
|
||||
);
|
||||
|
||||
export const subscribeBluetoothConnectionAllocations = (
|
||||
conn: Connection,
|
||||
callbackFunction: (
|
||||
bluetoothAllocationsData: BluetoothAllocationsData[]
|
||||
) => void,
|
||||
configEntryId?: string
|
||||
): Promise<() => Promise<void>> => {
|
||||
const params: { type: string; config_entry_id?: string } = {
|
||||
type: "bluetooth/subscribe_connection_allocations",
|
||||
};
|
||||
if (configEntryId) {
|
||||
params.config_entry_id = configEntryId;
|
||||
}
|
||||
return conn.subscribeMessage<BluetoothAllocationsData[]>(
|
||||
(bluetoothAllocationsData) => callbackFunction(bluetoothAllocationsData),
|
||||
params
|
||||
);
|
||||
};
|
@@ -181,3 +181,6 @@ export const updateCloudGoogleEntityConfig = (
|
||||
|
||||
export const cloudSyncGoogleAssistant = (hass: HomeAssistant) =>
|
||||
hass.callApi("POST", "cloud/google_actions/sync");
|
||||
|
||||
export const fetchSupportPackage = (hass: HomeAssistant) =>
|
||||
hass.callApi<string>("GET", "cloud/support_package");
|
||||
|
@@ -313,21 +313,34 @@ export const installHassioAddon = async (
|
||||
|
||||
export const updateHassioAddon = async (
|
||||
hass: HomeAssistant,
|
||||
slug: string
|
||||
slug: string,
|
||||
backup: boolean
|
||||
): Promise<void> => {
|
||||
if (atLeastVersion(hass.config.version, 2025, 2, 0)) {
|
||||
await hass.callWS({
|
||||
type: "hassio/update/addon",
|
||||
addon: slug,
|
||||
backup: backup,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/store/addons/${slug}/update`,
|
||||
method: "post",
|
||||
timeout: null,
|
||||
data: { backup },
|
||||
});
|
||||
} else {
|
||||
await hass.callApi<HassioResponse<void>>(
|
||||
"POST",
|
||||
`hassio/addons/${slug}/update`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>(
|
||||
"POST",
|
||||
`hassio/addons/${slug}/update`,
|
||||
{ backup }
|
||||
);
|
||||
};
|
||||
|
||||
export const restartHassioAddon = async (
|
||||
|
@@ -5,6 +5,7 @@ import type { HomeAssistant } from "../types";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
|
||||
export const integrationsWithPanel = {
|
||||
bluetooth: "config/bluetooth",
|
||||
matter: "config/matter",
|
||||
mqtt: "config/mqtt",
|
||||
thread: "config/thread",
|
||||
|
@@ -2,6 +2,8 @@ import type { HomeAssistant } from "../types";
|
||||
|
||||
export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
|
||||
export const SENSOR_DEVICE_CLASS_TIMESTAMP = "timestamp";
|
||||
export const SENSOR_DEVICE_CLASS_TEMPERATURE = "temperature";
|
||||
export const SENSOR_DEVICE_CLASS_HUMIDITY = "humidity";
|
||||
|
||||
export interface SensorDeviceClassUnits {
|
||||
units: string[];
|
||||
|
@@ -6,15 +6,27 @@ export const restartCore = async (hass: HomeAssistant) => {
|
||||
await hass.callService("homeassistant", "restart");
|
||||
};
|
||||
|
||||
export const updateCore = async (hass: HomeAssistant) => {
|
||||
export const updateCore = async (hass: HomeAssistant, backup: boolean) => {
|
||||
if (atLeastVersion(hass.config.version, 2025, 2, 0)) {
|
||||
await hass.callWS({
|
||||
type: "hassio/update/core",
|
||||
backup: backup,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/core/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
data: { backup },
|
||||
});
|
||||
} else {
|
||||
await hass.callApi<HassioResponse<void>>("POST", "hassio/core/update");
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", "hassio/core/update", {
|
||||
backup,
|
||||
});
|
||||
};
|
||||
|
@@ -13,6 +13,7 @@ import { caseInsensitiveStringCompare } from "../common/string/compare";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { showToast } from "../util/toast";
|
||||
import type { EntitySources } from "./entity_sources";
|
||||
|
||||
export enum UpdateEntityFeature {
|
||||
INSTALL = 1,
|
||||
@@ -60,6 +61,10 @@ export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) =>
|
||||
entity_id: entityId,
|
||||
});
|
||||
|
||||
const HOME_ASSISTANT_CORE_TITLE = "Home Assistant Core";
|
||||
const HOME_ASSISTANT_SUPERVISOR_TITLE = "Home Assistant Supervisor";
|
||||
const HOME_ASSISTANT_OS_TITLE = "Home Assistant Operating System";
|
||||
|
||||
export const filterUpdateEntities = (
|
||||
entities: HassEntities,
|
||||
language?: string
|
||||
@@ -69,22 +74,22 @@ export const filterUpdateEntities = (
|
||||
(entity) => computeStateDomain(entity) === "update"
|
||||
) as UpdateEntity[]
|
||||
).sort((a, b) => {
|
||||
if (a.attributes.title === "Home Assistant Core") {
|
||||
if (a.attributes.title === HOME_ASSISTANT_CORE_TITLE) {
|
||||
return -3;
|
||||
}
|
||||
if (b.attributes.title === "Home Assistant Core") {
|
||||
if (b.attributes.title === HOME_ASSISTANT_CORE_TITLE) {
|
||||
return 3;
|
||||
}
|
||||
if (a.attributes.title === "Home Assistant Operating System") {
|
||||
if (a.attributes.title === HOME_ASSISTANT_OS_TITLE) {
|
||||
return -2;
|
||||
}
|
||||
if (b.attributes.title === "Home Assistant Operating System") {
|
||||
if (b.attributes.title === HOME_ASSISTANT_OS_TITLE) {
|
||||
return 2;
|
||||
}
|
||||
if (a.attributes.title === "Home Assistant Supervisor") {
|
||||
if (a.attributes.title === HOME_ASSISTANT_SUPERVISOR_TITLE) {
|
||||
return -1;
|
||||
}
|
||||
if (b.attributes.title === "Home Assistant Supervisor") {
|
||||
if (b.attributes.title === HOME_ASSISTANT_SUPERVISOR_TITLE) {
|
||||
return 1;
|
||||
}
|
||||
return caseInsensitiveStringCompare(
|
||||
@@ -201,3 +206,32 @@ export const computeUpdateStateDisplay = (
|
||||
|
||||
return hass.formatEntityState(stateObj);
|
||||
};
|
||||
|
||||
type UpdateType = "addon" | "home_assistant" | "generic";
|
||||
|
||||
export const getUpdateType = (
|
||||
stateObj: UpdateEntity,
|
||||
entitySources: EntitySources
|
||||
): UpdateType => {
|
||||
const entity_id = stateObj.entity_id;
|
||||
const domain = entitySources[entity_id]?.domain;
|
||||
if (domain !== "hassio") {
|
||||
return "generic";
|
||||
}
|
||||
|
||||
const title = stateObj.attributes.title || "";
|
||||
if (title === HOME_ASSISTANT_CORE_TITLE) {
|
||||
return "home_assistant";
|
||||
}
|
||||
|
||||
if (
|
||||
![
|
||||
HOME_ASSISTANT_CORE_TITLE,
|
||||
HOME_ASSISTANT_SUPERVISOR_TITLE,
|
||||
HOME_ASSISTANT_OS_TITLE,
|
||||
].includes(title)
|
||||
) {
|
||||
return "addon";
|
||||
}
|
||||
return "generic";
|
||||
};
|
||||
|
@@ -312,32 +312,31 @@ class DataEntryFlowDialog extends LitElement {
|
||||
private async _processStep(
|
||||
step: DataEntryFlowStep | undefined | Promise<DataEntryFlowStep>
|
||||
): Promise<void> {
|
||||
if (step instanceof Promise) {
|
||||
this._loading = "loading_step";
|
||||
try {
|
||||
this._step = await step;
|
||||
} catch (err: any) {
|
||||
this.closeDialog();
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.error"
|
||||
),
|
||||
text: err?.body?.message,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
this._loading = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === undefined) {
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
this._loading = "loading_step";
|
||||
let _step: DataEntryFlowStep;
|
||||
try {
|
||||
_step = await step;
|
||||
} catch (err: any) {
|
||||
this.closeDialog();
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.error"
|
||||
),
|
||||
text: err?.body?.message,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
this._loading = undefined;
|
||||
}
|
||||
|
||||
this._step = undefined;
|
||||
await this.updateComplete;
|
||||
this._step = step;
|
||||
this._step = _step;
|
||||
}
|
||||
|
||||
private async _subscribeDataEntryFlowProgressed() {
|
||||
|
@@ -65,7 +65,8 @@ class StepFlowCreateEntry extends LitElement {
|
||||
|
||||
if (
|
||||
devices.length !== 1 ||
|
||||
devices[0].primary_config_entry !== this.step.result?.entry_id
|
||||
devices[0].primary_config_entry !== this.step.result?.entry_id ||
|
||||
this.step.result.domain === "voip"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { mdiAlertOutline } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-md-dialog";
|
||||
@@ -117,9 +116,7 @@ class DialogBox extends LitElement {
|
||||
@click=${this._confirm}
|
||||
?dialogInitialFocus=${!this._params.prompt &&
|
||||
!this._params.destructive}
|
||||
class=${classMap({
|
||||
destructive: this._params.destructive || false,
|
||||
})}
|
||||
?destructive=${this._params.destructive}
|
||||
>
|
||||
${this._params.confirmText
|
||||
? this._params.confirmText
|
||||
@@ -187,9 +184,6 @@ class DialogBox extends LitElement {
|
||||
.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.destructive {
|
||||
--mdc-theme-primary: var(--error-color);
|
||||
}
|
||||
ha-textfield {
|
||||
width: 100%;
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ import "@material/mwc-linear-progress/mwc-linear-progress";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { BINARY_STATE_OFF } from "../../../common/const";
|
||||
import { relativeTime } from "../../../common/datetime/relative_time";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -10,10 +11,18 @@ import "../../../components/ha-circular-progress";
|
||||
import "../../../components/ha-faded";
|
||||
import "../../../components/ha-formfield";
|
||||
import "../../../components/ha-markdown";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-md-list";
|
||||
import "../../../components/ha-md-list-item";
|
||||
import "../../../components/ha-switch";
|
||||
import type { HaSwitch } from "../../../components/ha-switch";
|
||||
import type { BackupConfig } from "../../../data/backup";
|
||||
import { fetchBackupConfig } from "../../../data/backup";
|
||||
import { isUnavailableState } from "../../../data/entity";
|
||||
import type { EntitySources } from "../../../data/entity_sources";
|
||||
import { fetchEntitySourcesWithCache } from "../../../data/entity_sources";
|
||||
import type { UpdateEntity } from "../../../data/update";
|
||||
import {
|
||||
getUpdateType,
|
||||
UpdateEntityFeature,
|
||||
updateIsInstalling,
|
||||
updateReleaseNotes,
|
||||
@@ -33,6 +42,103 @@ class MoreInfoUpdate extends LitElement {
|
||||
|
||||
@state() private _markdownLoading = true;
|
||||
|
||||
@state() private _backupConfig?: BackupConfig;
|
||||
|
||||
@state() private _entitySources?: EntitySources;
|
||||
|
||||
private async _fetchBackupConfig() {
|
||||
const { config } = await fetchBackupConfig(this.hass);
|
||||
this._backupConfig = config;
|
||||
}
|
||||
|
||||
private async _fetchEntitySources() {
|
||||
this._entitySources = await fetchEntitySourcesWithCache(this.hass);
|
||||
}
|
||||
|
||||
private _computeCreateBackupTexts():
|
||||
| { title: string; description?: string }
|
||||
| undefined {
|
||||
if (
|
||||
!this.stateObj ||
|
||||
!supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const updateType = this._entitySources
|
||||
? getUpdateType(this.stateObj, this._entitySources)
|
||||
: "generic";
|
||||
|
||||
// Automatic or manual for Home Assistant update
|
||||
if (updateType === "home_assistant") {
|
||||
const isBackupConfigValid =
|
||||
!!this._backupConfig &&
|
||||
!!this._backupConfig.create_backup.password &&
|
||||
this._backupConfig.create_backup.agent_ids.length > 0;
|
||||
|
||||
if (!isBackupConfigValid) {
|
||||
return {
|
||||
title: this.hass.localize(
|
||||
"ui.dialogs.more_info_control.update.create_backup.manual"
|
||||
),
|
||||
description: this.hass.localize(
|
||||
"ui.dialogs.more_info_control.update.create_backup.manual_description"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const lastAutomaticBackupDate = this._backupConfig
|
||||
?.last_completed_automatic_backup
|
||||
? new Date(this._backupConfig?.last_completed_automatic_backup)
|
||||
: null;
|
||||
const now = new Date();
|
||||
|
||||
return {
|
||||
title: this.hass.localize(
|
||||
"ui.dialogs.more_info_control.update.create_backup.automatic"
|
||||
),
|
||||
description: lastAutomaticBackupDate
|
||||
? this.hass.localize(
|
||||
"ui.dialogs.more_info_control.update.create_backup.automatic_description_last",
|
||||
{
|
||||
relative_time: relativeTime(
|
||||
lastAutomaticBackupDate,
|
||||
this.hass.locale,
|
||||
now,
|
||||
true
|
||||
),
|
||||
}
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.dialogs.more_info_control.update.create_backup.automatic_description_none"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Addon backup
|
||||
if (updateType === "addon") {
|
||||
const version = this.stateObj.attributes.installed_version;
|
||||
return {
|
||||
title: this.hass.localize(
|
||||
"ui.dialogs.more_info_control.update.create_backup.addon"
|
||||
),
|
||||
description: version
|
||||
? this.hass.localize(
|
||||
"ui.dialogs.more_info_control.update.create_backup.addon_description",
|
||||
{ version: version }
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to generic UI
|
||||
return {
|
||||
title: this.hass.localize(
|
||||
"ui.dialogs.more_info_control.update.create_backup.generic"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (
|
||||
!this.hass ||
|
||||
@@ -47,6 +153,8 @@ class MoreInfoUpdate extends LitElement {
|
||||
this.stateObj.attributes.skipped_version ===
|
||||
this.stateObj.attributes.latest_version;
|
||||
|
||||
const createBackupTexts = this._computeCreateBackupTexts();
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<div class="summary">
|
||||
@@ -133,6 +241,27 @@ class MoreInfoUpdate extends LitElement {
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="footer">
|
||||
${createBackupTexts
|
||||
? html`
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">${createBackupTexts.title}</span>
|
||||
${createBackupTexts.description
|
||||
? html`
|
||||
<span slot="supporting-text">
|
||||
${createBackupTexts.description}
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
<ha-switch
|
||||
slot="end"
|
||||
id="create-backup"
|
||||
.disabled=${updateIsInstalling(this.stateObj)}
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
`
|
||||
: nothing}
|
||||
<div class="actions">
|
||||
${this.stateObj.state === BINARY_STATE_OFF &&
|
||||
this.stateObj.attributes.skipped_version
|
||||
@@ -186,6 +315,14 @@ class MoreInfoUpdate extends LitElement {
|
||||
if (supportsFeature(this.stateObj!, UpdateEntityFeature.RELEASE_NOTES)) {
|
||||
this._fetchReleaseNotes();
|
||||
}
|
||||
if (supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
|
||||
this._fetchEntitySources().then(() => {
|
||||
const type = getUpdateType(this.stateObj!, this._entitySources!);
|
||||
if (type === "home_assistant") {
|
||||
this._fetchBackupConfig();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async _markdownLoaded() {
|
||||
@@ -205,11 +342,28 @@ class MoreInfoUpdate extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
get _shouldCreateBackup(): boolean {
|
||||
if (!supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
|
||||
return false;
|
||||
}
|
||||
const createBackupSwitch = this.shadowRoot?.getElementById(
|
||||
"create-backup"
|
||||
) as HaSwitch;
|
||||
if (createBackupSwitch) {
|
||||
return createBackupSwitch.checked;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -289,12 +443,20 @@ class MoreInfoUpdate extends LitElement {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
ha-settings-row {
|
||||
ha-md-list {
|
||||
width: 100%;
|
||||
padding: 0 24px;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: -16px;
|
||||
margin-top: -4px;
|
||||
--md-sys-color-surface: var(
|
||||
--ha-dialog-surface-background,
|
||||
var(--mdc-theme-surface, #fff)
|
||||
);
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
--md-list-item-leading-space: 24px;
|
||||
--md-list-item-trailing-space: 24px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
@@ -47,6 +47,8 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
||||
|
||||
@state() private _assistConfiguration?: AssistSatelliteConfiguration;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _previousSteps: STEP[] = [];
|
||||
|
||||
private _nextStep?: STEP;
|
||||
@@ -165,79 +167,86 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
||||
"update"
|
||||
)}
|
||||
></ha-voice-assistant-setup-step-update>`
|
||||
: assistEntityState?.state === UNAVAILABLE
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.satellite_wizard.not_available"
|
||||
)
|
||||
: this._step === STEP.CHECK
|
||||
? html`<ha-voice-assistant-setup-step-check
|
||||
.hass=${this.hass}
|
||||
.assistEntityId=${assistSatelliteEntityId}
|
||||
></ha-voice-assistant-setup-step-check>`
|
||||
: this._step === STEP.WAKEWORD
|
||||
? html`<ha-voice-assistant-setup-step-wake-word
|
||||
: this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: assistEntityState?.state === UNAVAILABLE
|
||||
? html`<ha-alert alert-type="error"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.satellite_wizard.not_available"
|
||||
)}</ha-alert
|
||||
>`
|
||||
: this._step === STEP.CHECK
|
||||
? html`<ha-voice-assistant-setup-step-check
|
||||
.hass=${this.hass}
|
||||
.assistConfiguration=${this._assistConfiguration}
|
||||
.assistEntityId=${assistSatelliteEntityId}
|
||||
.deviceEntities=${this._deviceEntities(
|
||||
this._params.deviceId,
|
||||
this.hass.entities
|
||||
)}
|
||||
></ha-voice-assistant-setup-step-wake-word>`
|
||||
: this._step === STEP.CHANGE_WAKEWORD
|
||||
? html`
|
||||
<ha-voice-assistant-setup-step-change-wake-word
|
||||
.hass=${this.hass}
|
||||
.assistConfiguration=${this._assistConfiguration}
|
||||
.assistEntityId=${assistSatelliteEntityId}
|
||||
></ha-voice-assistant-setup-step-change-wake-word>
|
||||
`
|
||||
: this._step === STEP.AREA
|
||||
></ha-voice-assistant-setup-step-check>`
|
||||
: this._step === STEP.WAKEWORD
|
||||
? html`<ha-voice-assistant-setup-step-wake-word
|
||||
.hass=${this.hass}
|
||||
.assistConfiguration=${this._assistConfiguration}
|
||||
.assistEntityId=${assistSatelliteEntityId}
|
||||
.deviceEntities=${this._deviceEntities(
|
||||
this._params.deviceId,
|
||||
this.hass.entities
|
||||
)}
|
||||
></ha-voice-assistant-setup-step-wake-word>`
|
||||
: this._step === STEP.CHANGE_WAKEWORD
|
||||
? html`
|
||||
<ha-voice-assistant-setup-step-area
|
||||
.hass=${this.hass}
|
||||
.deviceId=${this._params.deviceId}
|
||||
></ha-voice-assistant-setup-step-area>
|
||||
`
|
||||
: this._step === STEP.PIPELINE
|
||||
? html`<ha-voice-assistant-setup-step-pipeline
|
||||
<ha-voice-assistant-setup-step-change-wake-word
|
||||
.hass=${this.hass}
|
||||
.assistConfiguration=${this._assistConfiguration}
|
||||
.assistEntityId=${assistSatelliteEntityId}
|
||||
></ha-voice-assistant-setup-step-pipeline>`
|
||||
: this._step === STEP.CLOUD
|
||||
? html`<ha-voice-assistant-setup-step-cloud
|
||||
></ha-voice-assistant-setup-step-change-wake-word>
|
||||
`
|
||||
: this._step === STEP.AREA
|
||||
? html`
|
||||
<ha-voice-assistant-setup-step-area
|
||||
.hass=${this.hass}
|
||||
></ha-voice-assistant-setup-step-cloud>`
|
||||
: this._step === STEP.LOCAL
|
||||
? html`<ha-voice-assistant-setup-step-local
|
||||
.deviceId=${this._params.deviceId}
|
||||
></ha-voice-assistant-setup-step-area>
|
||||
`
|
||||
: this._step === STEP.PIPELINE
|
||||
? html`<ha-voice-assistant-setup-step-pipeline
|
||||
.hass=${this.hass}
|
||||
.assistConfiguration=${this._assistConfiguration}
|
||||
.assistEntityId=${assistSatelliteEntityId}
|
||||
></ha-voice-assistant-setup-step-pipeline>`
|
||||
: this._step === STEP.CLOUD
|
||||
? html`<ha-voice-assistant-setup-step-cloud
|
||||
.hass=${this.hass}
|
||||
.assistConfiguration=${this
|
||||
._assistConfiguration}
|
||||
></ha-voice-assistant-setup-step-local>`
|
||||
: this._step === STEP.SUCCESS
|
||||
? html`<ha-voice-assistant-setup-step-success
|
||||
></ha-voice-assistant-setup-step-cloud>`
|
||||
: this._step === STEP.LOCAL
|
||||
? html`<ha-voice-assistant-setup-step-local
|
||||
.hass=${this.hass}
|
||||
.assistConfiguration=${this
|
||||
._assistConfiguration}
|
||||
.assistEntityId=${assistSatelliteEntityId}
|
||||
></ha-voice-assistant-setup-step-success>`
|
||||
: nothing}
|
||||
></ha-voice-assistant-setup-step-local>`
|
||||
: this._step === STEP.SUCCESS
|
||||
? html`<ha-voice-assistant-setup-step-success
|
||||
.hass=${this.hass}
|
||||
.assistConfiguration=${this
|
||||
._assistConfiguration}
|
||||
.assistEntityId=${assistSatelliteEntityId}
|
||||
></ha-voice-assistant-setup-step-success>`
|
||||
: nothing}
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _fetchAssistConfiguration() {
|
||||
this._assistConfiguration = await fetchAssistSatelliteConfiguration(
|
||||
this.hass,
|
||||
this._findDomainEntityId(
|
||||
this._params!.deviceId,
|
||||
this.hass.entities,
|
||||
"assist_satellite"
|
||||
)!
|
||||
);
|
||||
return this._assistConfiguration;
|
||||
try {
|
||||
this._assistConfiguration = await fetchAssistSatelliteConfiguration(
|
||||
this.hass,
|
||||
this._findDomainEntityId(
|
||||
this._params!.deviceId,
|
||||
this.hass.entities,
|
||||
"assist_satellite"
|
||||
)!
|
||||
);
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private _goToPreviousStep() {
|
||||
@@ -293,6 +302,10 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
||||
.skip-btn {
|
||||
margin-top: 6px;
|
||||
}
|
||||
ha-alert {
|
||||
margin: 24px;
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@@ -85,7 +85,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
|
||||
<div class="rows">
|
||||
${this.assistConfiguration &&
|
||||
this.assistConfiguration.available_wake_words.length > 1
|
||||
? html` <div class="row">
|
||||
? html`<div class="row">
|
||||
<ha-select
|
||||
.label=${"Wake word"}
|
||||
@closed=${stopPropagation}
|
||||
|
@@ -44,6 +44,15 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
|
||||
protected override willUpdate(changedProperties: PropertyValues) {
|
||||
super.willUpdate(changedProperties);
|
||||
|
||||
if (changedProperties.has("assistConfiguration")) {
|
||||
if (
|
||||
this.assistConfiguration &&
|
||||
!this.assistConfiguration.available_wake_words.length
|
||||
) {
|
||||
this._nextStep();
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProperties.has("assistEntityId")) {
|
||||
this._detected = false;
|
||||
this._muteSwitchEntity = this.deviceEntities?.find(
|
||||
@@ -135,13 +144,16 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
|
||||
>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="footer centered">
|
||||
<ha-button @click=${this._changeWakeWord}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>`;
|
||||
${this.assistConfiguration &&
|
||||
this.assistConfiguration.available_wake_words.length > 1
|
||||
? html`<div class="footer centered">
|
||||
<ha-button @click=${this._changeWakeWord}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>`
|
||||
: nothing}`;
|
||||
}
|
||||
|
||||
private async _listenWakeWord() {
|
||||
|
@@ -95,7 +95,10 @@ export class HassRouterPage extends ReactiveElement {
|
||||
const defaultPage = routerOptions.defaultPage;
|
||||
|
||||
if (route && route.path === "" && defaultPage !== undefined) {
|
||||
navigate(`${route.prefix}/${defaultPage}`, { replace: true });
|
||||
const queryParams = window.location.search;
|
||||
navigate(`${route.prefix}/${defaultPage}${queryParams}`, {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
let newPage = route
|
||||
|
@@ -5,7 +5,7 @@ import {
|
||||
mdiArrowDown,
|
||||
mdiArrowUp,
|
||||
mdiClose,
|
||||
mdiCog,
|
||||
mdiTableCog,
|
||||
mdiFilterVariant,
|
||||
mdiFilterVariantRemove,
|
||||
mdiFormatListChecks,
|
||||
@@ -309,7 +309,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
@click=${this._openSettings}
|
||||
.title=${localize("ui.components.subpage-data-table.settings")}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiCog}></ha-svg-icon>
|
||||
<ha-svg-icon slot="icon" .path=${mdiTableCog}></ha-svg-icon>
|
||||
</ha-assist-chip>`;
|
||||
|
||||
return html`
|
||||
@@ -355,7 +355,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
></ha-assist-chip>
|
||||
<ha-md-menu-item
|
||||
.value=${undefined}
|
||||
@click=${this._selectAll}
|
||||
.clickAction=${this._selectAll}
|
||||
>
|
||||
<div slot="headline">
|
||||
${localize("ui.components.subpage-data-table.select_all")}
|
||||
@@ -363,7 +363,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu-item
|
||||
.value=${undefined}
|
||||
@click=${this._selectNone}
|
||||
.clickAction=${this._selectNone}
|
||||
>
|
||||
<div slot="headline">
|
||||
${localize(
|
||||
@@ -374,7 +374,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
|
||||
<ha-md-menu-item
|
||||
.value=${undefined}
|
||||
@click=${this._disableSelectMode}
|
||||
.clickAction=${this._disableSelectMode}
|
||||
>
|
||||
<div slot="headline">
|
||||
${localize(
|
||||
@@ -500,7 +500,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
? html`
|
||||
<ha-md-menu-item
|
||||
.value=${id}
|
||||
@click=${this._handleGroupBy}
|
||||
.clickAction=${this._handleGroupBy}
|
||||
.selected=${id === this._groupColumn}
|
||||
class=${classMap({ selected: id === this._groupColumn })}
|
||||
>
|
||||
@@ -511,7 +511,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
)}
|
||||
<ha-md-menu-item
|
||||
.value=${undefined}
|
||||
@click=${this._handleGroupBy}
|
||||
.clickAction=${this._handleGroupBy}
|
||||
.selected=${this._groupColumn === undefined}
|
||||
class=${classMap({ selected: this._groupColumn === undefined })}
|
||||
>
|
||||
@@ -519,7 +519,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
</ha-md-menu-item>
|
||||
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
|
||||
<ha-md-menu-item
|
||||
@click=${this._collapseAllGroups}
|
||||
.clickAction=${this._collapseAllGroups}
|
||||
.disabled=${this._groupColumn === undefined}
|
||||
>
|
||||
<ha-svg-icon
|
||||
@@ -529,7 +529,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
${localize("ui.components.subpage-data-table.collapse_all_groups")}
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu-item
|
||||
@click=${this._expandAllGroups}
|
||||
.clickAction=${this._expandAllGroups}
|
||||
.disabled=${this._groupColumn === undefined}
|
||||
>
|
||||
<ha-svg-icon
|
||||
@@ -546,6 +546,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
<ha-md-menu-item
|
||||
.value=${id}
|
||||
@click=${this._handleSortBy}
|
||||
@keydown=${this._handleSortBy}
|
||||
keep-open
|
||||
.selected=${id === this._sortColumn}
|
||||
class=${classMap({ selected: id === this._sortColumn })}
|
||||
@@ -623,6 +624,8 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _handleSortBy(ev) {
|
||||
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") return;
|
||||
|
||||
const columnId = ev.currentTarget.value;
|
||||
if (!this._sortDirection || this._sortColumn !== columnId) {
|
||||
this._sortDirection = "asc";
|
||||
@@ -639,9 +642,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleGroupBy(ev) {
|
||||
this._setGroupColumn(ev.currentTarget.value);
|
||||
}
|
||||
private _handleGroupBy = (item) => {
|
||||
this._setGroupColumn(item.value);
|
||||
};
|
||||
|
||||
private _setGroupColumn(columnId: string) {
|
||||
this._groupColumn = columnId;
|
||||
@@ -665,30 +668,30 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _collapseAllGroups() {
|
||||
private _collapseAllGroups = () => {
|
||||
this._dataTable.collapseAllGroups();
|
||||
}
|
||||
};
|
||||
|
||||
private _expandAllGroups() {
|
||||
private _expandAllGroups = () => {
|
||||
this._dataTable.expandAllGroups();
|
||||
}
|
||||
};
|
||||
|
||||
private _enableSelectMode() {
|
||||
this._selectMode = true;
|
||||
}
|
||||
|
||||
private _disableSelectMode() {
|
||||
private _disableSelectMode = () => {
|
||||
this._selectMode = false;
|
||||
this._dataTable.clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
private _selectAll() {
|
||||
private _selectAll = () => {
|
||||
this._dataTable.selectAll();
|
||||
}
|
||||
};
|
||||
|
||||
private _selectNone() {
|
||||
private _selectNone = () => {
|
||||
this._dataTable.clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
if (this.filter === ev.detail.value) {
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import "@material/mwc-button/mwc-button";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
@@ -8,6 +7,7 @@ import "../../components/ha-switch";
|
||||
import { RecurrenceRange } from "../../data/calendar";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { ConfirmEventDialogBoxParams } from "./show-confirm-event-dialog-box";
|
||||
import "../../components/ha-button";
|
||||
|
||||
@customElement("confirm-event-dialog-box")
|
||||
class ConfirmEventDialogBox extends LitElement {
|
||||
@@ -40,26 +40,26 @@ class ConfirmEventDialogBox extends LitElement {
|
||||
<div>
|
||||
<p>${this._params.text}</p>
|
||||
</div>
|
||||
<mwc-button @click=${this._dismiss} slot="secondaryAction">
|
||||
<ha-button @click=${this._dismiss} slot="secondaryAction">
|
||||
${this.hass.localize("ui.dialogs.generic.cancel")}
|
||||
</mwc-button>
|
||||
<mwc-button
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._confirm}
|
||||
dialogInitialFocus
|
||||
class="destructive"
|
||||
destructive
|
||||
>
|
||||
${this._params.confirmText}
|
||||
</mwc-button>
|
||||
</ha-button>
|
||||
${this._params.confirmFutureText
|
||||
? html`
|
||||
<mwc-button
|
||||
<ha-button
|
||||
@click=${this._confirmFuture}
|
||||
class="destructive"
|
||||
slot="primaryAction"
|
||||
destructive
|
||||
>
|
||||
${this._params.confirmFutureText}
|
||||
</mwc-button>
|
||||
</ha-button>
|
||||
`
|
||||
: ""}
|
||||
</ha-dialog>
|
||||
@@ -120,9 +120,6 @@ class ConfirmEventDialogBox extends LitElement {
|
||||
.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.destructive {
|
||||
--mdc-theme-primary: var(--error-color);
|
||||
}
|
||||
ha-dialog {
|
||||
/* Place above other dialogs */
|
||||
--dialog-z-index: 104;
|
||||
|
@@ -106,6 +106,7 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
},
|
||||
actions: {
|
||||
title: "",
|
||||
label: localize("ui.panel.config.generic.headers.actions"),
|
||||
type: "overflow-menu",
|
||||
showNarrow: true,
|
||||
hideable: false,
|
||||
|
@@ -3,6 +3,7 @@ import "@material/mwc-list/mwc-list";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-aliases-editor";
|
||||
@@ -12,6 +13,8 @@ import type { HaPictureUpload } from "../../../components/ha-picture-upload";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-icon-picker";
|
||||
import "../../../components/ha-floor-picker";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/ha-textfield";
|
||||
import "../../../components/ha-labels-picker";
|
||||
import type { AreaRegistryEntryMutableParams } from "../../../data/area_registry";
|
||||
@@ -19,6 +22,10 @@ import type { CropOptions } from "../../../dialogs/image-cropper-dialog/show-ima
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry-detail";
|
||||
import {
|
||||
SENSOR_DEVICE_CLASS_HUMIDITY,
|
||||
SENSOR_DEVICE_CLASS_TEMPERATURE,
|
||||
} from "../../../data/sensor";
|
||||
|
||||
const cropOptions: CropOptions = {
|
||||
round: false,
|
||||
@@ -27,6 +34,10 @@ const cropOptions: CropOptions = {
|
||||
aspectRatio: 1.78,
|
||||
};
|
||||
|
||||
const SENSOR_DOMAINS = ["sensor"];
|
||||
const TEMPERATURE_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_TEMPERATURE];
|
||||
const HUMIDITY_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_HUMIDITY];
|
||||
|
||||
class DialogAreaDetail extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -42,6 +53,10 @@ class DialogAreaDetail extends LitElement {
|
||||
|
||||
@state() private _floor!: string | null;
|
||||
|
||||
@state() private _temperatureEntity!: string | null;
|
||||
|
||||
@state() private _humidityEntity!: string | null;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _params?: AreaRegistryDetailDialogParams;
|
||||
@@ -53,14 +68,26 @@ class DialogAreaDetail extends LitElement {
|
||||
): Promise<void> {
|
||||
this._params = params;
|
||||
this._error = undefined;
|
||||
this._name = this._params.entry
|
||||
? this._params.entry.name
|
||||
: this._params.suggestedName || "";
|
||||
this._aliases = this._params.entry ? this._params.entry.aliases : [];
|
||||
this._labels = this._params.entry ? this._params.entry.labels : [];
|
||||
this._picture = this._params.entry?.picture || null;
|
||||
this._icon = this._params.entry?.icon || null;
|
||||
this._floor = this._params.entry?.floor_id || null;
|
||||
if (this._params.entry) {
|
||||
this._name = this._params.entry.name;
|
||||
this._aliases = this._params.entry.aliases;
|
||||
this._labels = this._params.entry.labels;
|
||||
this._picture = this._params.entry.picture;
|
||||
this._icon = this._params.entry.icon;
|
||||
this._floor = this._params.entry.floor_id;
|
||||
this._temperatureEntity = this._params.entry.temperature_entity_id;
|
||||
this._humidityEntity = this._params.entry.humidity_entity_id;
|
||||
} else {
|
||||
this._name = this._params.suggestedName || "";
|
||||
this._aliases = [];
|
||||
this._labels = [];
|
||||
this._picture = null;
|
||||
this._icon = null;
|
||||
this._floor = null;
|
||||
this._temperatureEntity = null;
|
||||
this._humidityEntity = null;
|
||||
}
|
||||
|
||||
await this.updateComplete;
|
||||
}
|
||||
|
||||
@@ -76,6 +103,7 @@ class DialogAreaDetail extends LitElement {
|
||||
}
|
||||
const entry = this._params.entry;
|
||||
const nameInvalid = !this._isNameValid();
|
||||
const isNew = !entry;
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@@ -161,6 +189,40 @@ class DialogAreaDetail extends LitElement {
|
||||
.aliases=${this._aliases}
|
||||
@value-changed=${this._aliasesChanged}
|
||||
></ha-aliases-editor>
|
||||
|
||||
${!isNew
|
||||
? html`
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.areas.editor.temperature_entity"
|
||||
)}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.areas.editor.temperature_entity_description"
|
||||
)}
|
||||
.value=${this._temperatureEntity}
|
||||
.includeDomains=${SENSOR_DOMAINS}
|
||||
.includeDeviceClasses=${TEMPERATURE_DEVICE_CLASSES}
|
||||
.entityFilter=${this._areaEntityFilter}
|
||||
@value-changed=${this._sensorChanged}
|
||||
></ha-entity-picker>
|
||||
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.areas.editor.humidity_entity"
|
||||
)}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.areas.editor.humidity_entity_description"
|
||||
)}
|
||||
.value=${this._humidityEntity}
|
||||
.includeDomains=${SENSOR_DOMAINS}
|
||||
.includeDeviceClasses=${HUMIDITY_DEVICE_CLASSES}
|
||||
.entityFilter=${this._areaEntityFilter}
|
||||
@value-changed=${this._sensorChanged}
|
||||
></ha-entity-picker>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<mwc-button slot="secondaryAction" @click=${this.closeDialog}>
|
||||
@@ -183,6 +245,22 @@ class DialogAreaDetail extends LitElement {
|
||||
return this._name.trim() !== "";
|
||||
}
|
||||
|
||||
private _areaEntityFilter = (stateObj: HassEntity): boolean => {
|
||||
const entityReg = this.hass.entities[stateObj.entity_id];
|
||||
if (!entityReg) {
|
||||
return false;
|
||||
}
|
||||
const areaId = this._params!.entry!.area_id;
|
||||
if (entityReg.area_id === areaId) {
|
||||
return true;
|
||||
}
|
||||
if (!entityReg.device_id) {
|
||||
return false;
|
||||
}
|
||||
const deviceReg = this.hass.devices[entityReg.device_id];
|
||||
return deviceReg && deviceReg.area_id === areaId;
|
||||
};
|
||||
|
||||
private _nameChanged(ev) {
|
||||
this._error = undefined;
|
||||
this._name = ev.target.value;
|
||||
@@ -208,6 +286,16 @@ class DialogAreaDetail extends LitElement {
|
||||
this._picture = (ev.target as HaPictureUpload).value;
|
||||
}
|
||||
|
||||
private _aliasesChanged(ev: CustomEvent): void {
|
||||
this._aliases = ev.detail.value;
|
||||
}
|
||||
|
||||
private _sensorChanged(ev: CustomEvent): void {
|
||||
const deviceClass = (ev.target as HaEntityPicker).includeDeviceClasses![0];
|
||||
const key = `_${deviceClass}Entity`;
|
||||
this[key] = ev.detail.value || null;
|
||||
}
|
||||
|
||||
private async _updateEntry() {
|
||||
const create = !this._params!.entry;
|
||||
this._submitting = true;
|
||||
@@ -219,6 +307,8 @@ class DialogAreaDetail extends LitElement {
|
||||
floor_id: this._floor || (create ? undefined : null),
|
||||
labels: this._labels || null,
|
||||
aliases: this._aliases,
|
||||
temperature_entity_id: this._temperatureEntity,
|
||||
humidity_entity_id: this._humidityEntity,
|
||||
};
|
||||
if (create) {
|
||||
await this._params!.createEntry!(values);
|
||||
@@ -235,17 +325,14 @@ class DialogAreaDetail extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _aliasesChanged(ev: CustomEvent): void {
|
||||
this._aliases = ev.detail.value;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-textfield,
|
||||
ha-icon-picker,
|
||||
ha-aliases-editor,
|
||||
ha-entity-picker,
|
||||
ha-floor-picker,
|
||||
ha-icon-picker,
|
||||
ha-labels-picker,
|
||||
ha-picture-upload {
|
||||
display: block;
|
||||
|
@@ -48,7 +48,7 @@ export class HaDelayAction extends LitElement implements ActionElement {
|
||||
)}
|
||||
.disabled=${this.disabled}
|
||||
.data=${this._timeData}
|
||||
enableMillisecond
|
||||
enable-millisecond
|
||||
required
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-duration-input>`;
|
||||
|
@@ -38,7 +38,7 @@ export class HaWaitForTriggerAction
|
||||
)}
|
||||
.data=${timeData}
|
||||
.disabled=${this.disabled}
|
||||
enableMillisecond
|
||||
enable-millisecond
|
||||
@value-changed=${this._timeoutChanged}
|
||||
></ha-duration-input>
|
||||
<ha-formfield
|
||||
|
@@ -20,13 +20,12 @@ import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type {
|
||||
AutomationRenameDialogParams,
|
||||
EntityRegistryUpdate,
|
||||
ScriptRenameDialogParams,
|
||||
} from "./show-dialog-automation-rename";
|
||||
SaveDialogParams,
|
||||
} from "./show-dialog-automation-save";
|
||||
|
||||
@customElement("ha-dialog-automation-rename")
|
||||
class DialogAutomationRename extends LitElement implements HassDialog {
|
||||
@customElement("ha-dialog-automation-save")
|
||||
class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _opened = false;
|
||||
@@ -37,7 +36,7 @@ class DialogAutomationRename extends LitElement implements HassDialog {
|
||||
|
||||
@state() private _entryUpdates!: EntityRegistryUpdate;
|
||||
|
||||
private _params!: AutomationRenameDialogParams | ScriptRenameDialogParams;
|
||||
private _params!: SaveDialogParams;
|
||||
|
||||
private _newName?: string;
|
||||
|
||||
@@ -45,9 +44,7 @@ class DialogAutomationRename extends LitElement implements HassDialog {
|
||||
|
||||
private _newDescription?: string;
|
||||
|
||||
public showDialog(
|
||||
params: AutomationRenameDialogParams | ScriptRenameDialogParams
|
||||
): void {
|
||||
public showDialog(params: SaveDialogParams): void {
|
||||
this._opened = true;
|
||||
this._params = params;
|
||||
this._newIcon = "icon" in params.config ? params.config.icon : undefined;
|
||||
@@ -95,20 +92,153 @@ class DialogAutomationRename extends LitElement implements HassDialog {
|
||||
`;
|
||||
}
|
||||
|
||||
protected _renderDiscard() {
|
||||
if (!this._params.onDiscard) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-button
|
||||
@click=${this._handleDiscard}
|
||||
slot="secondaryAction"
|
||||
class="destructive"
|
||||
>
|
||||
${this.hass.localize("ui.common.dont_save")}
|
||||
</ha-button>
|
||||
`;
|
||||
}
|
||||
|
||||
protected _renderInputs() {
|
||||
if (this._params.hideInputs) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-textfield
|
||||
dialogInitialFocus
|
||||
.value=${this._newName}
|
||||
.placeholder=${this.hass.localize(
|
||||
`ui.panel.config.${this._params.domain}.editor.default_name`
|
||||
)}
|
||||
.label=${this.hass.localize("ui.panel.config.automation.editor.alias")}
|
||||
required
|
||||
type="string"
|
||||
@input=${this._valueChanged}
|
||||
></ha-textfield>
|
||||
|
||||
${this._params.domain === "script" &&
|
||||
this._visibleOptionals.includes("icon")
|
||||
? html`
|
||||
<ha-icon-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.icon"
|
||||
)}
|
||||
.value=${this._newIcon}
|
||||
@value-changed=${this._iconChanged}
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="fallback"
|
||||
domain=${this._params.domain}
|
||||
.hass=${this.hass}
|
||||
>
|
||||
</ha-domain-icon>
|
||||
</ha-icon-picker>
|
||||
`
|
||||
: nothing}
|
||||
${this._visibleOptionals.includes("description")
|
||||
? html` <ha-textarea
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.description.label"
|
||||
)}
|
||||
.placeholder=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.description.placeholder"
|
||||
)}
|
||||
name="description"
|
||||
autogrow
|
||||
.value=${this._newDescription}
|
||||
@input=${this._valueChanged}
|
||||
></ha-textarea>`
|
||||
: nothing}
|
||||
${this._visibleOptionals.includes("category")
|
||||
? html` <ha-category-picker
|
||||
id="category"
|
||||
.hass=${this.hass}
|
||||
.scope=${this._params.domain}
|
||||
.value=${this._entryUpdates.category}
|
||||
@value-changed=${this._registryEntryChanged}
|
||||
></ha-category-picker>`
|
||||
: nothing}
|
||||
${this._visibleOptionals.includes("labels")
|
||||
? html` <ha-labels-picker
|
||||
id="labels"
|
||||
.hass=${this.hass}
|
||||
.value=${this._entryUpdates.labels}
|
||||
@value-changed=${this._registryEntryChanged}
|
||||
></ha-labels-picker>`
|
||||
: nothing}
|
||||
${this._visibleOptionals.includes("area")
|
||||
? html` <ha-area-picker
|
||||
id="area"
|
||||
.hass=${this.hass}
|
||||
.value=${this._entryUpdates.area}
|
||||
@value-changed=${this._registryEntryChanged}
|
||||
></ha-area-picker>`
|
||||
: nothing}
|
||||
|
||||
<ha-chip-set>
|
||||
${this._renderOptionalChip(
|
||||
"description",
|
||||
this.hass.localize(
|
||||
"ui.panel.config.automation.editor.dialog.add_description"
|
||||
)
|
||||
)}
|
||||
${this._params.domain === "script"
|
||||
? this._renderOptionalChip(
|
||||
"icon",
|
||||
this.hass.localize(
|
||||
"ui.panel.config.automation.editor.dialog.add_icon"
|
||||
)
|
||||
)
|
||||
: nothing}
|
||||
${this._renderOptionalChip(
|
||||
"area",
|
||||
this.hass.localize(
|
||||
"ui.panel.config.automation.editor.dialog.add_area"
|
||||
)
|
||||
)}
|
||||
${this._renderOptionalChip(
|
||||
"category",
|
||||
this.hass.localize(
|
||||
"ui.panel.config.automation.editor.dialog.add_category"
|
||||
)
|
||||
)}
|
||||
${this._renderOptionalChip(
|
||||
"labels",
|
||||
this.hass.localize(
|
||||
"ui.panel.config.automation.editor.dialog.add_labels"
|
||||
)
|
||||
)}
|
||||
</ha-chip-set>
|
||||
`;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._opened) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const title = this.hass.localize(
|
||||
this._params.config.alias
|
||||
? "ui.panel.config.automation.editor.rename"
|
||||
: "ui.panel.config.automation.editor.save"
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
scrimClickAction
|
||||
@closed=${this.closeDialog}
|
||||
.heading=${this.hass.localize(
|
||||
this._params.config.alias
|
||||
? "ui.panel.config.automation.editor.rename"
|
||||
: "ui.panel.config.automation.editor.save"
|
||||
)}
|
||||
.heading=${title}
|
||||
>
|
||||
<ha-dialog-header slot="heading">
|
||||
<ha-icon-button
|
||||
@@ -117,13 +247,7 @@ class DialogAutomationRename extends LitElement implements HassDialog {
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
<span slot="title"
|
||||
>${this.hass.localize(
|
||||
this._params.config.alias
|
||||
? "ui.panel.config.automation.editor.rename"
|
||||
: "ui.panel.config.automation.editor.save"
|
||||
)}</span
|
||||
>
|
||||
<span slot="title">${this._params.title || title}</span>
|
||||
</ha-dialog-header>
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error"
|
||||
@@ -132,114 +256,10 @@ class DialogAutomationRename extends LitElement implements HassDialog {
|
||||
)}</ha-alert
|
||||
>`
|
||||
: ""}
|
||||
<ha-textfield
|
||||
dialogInitialFocus
|
||||
.value=${this._newName}
|
||||
.placeholder=${this.hass.localize(
|
||||
`ui.panel.config.${this._params.domain}.editor.default_name`
|
||||
)}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.alias"
|
||||
)}
|
||||
required
|
||||
type="string"
|
||||
@input=${this._valueChanged}
|
||||
></ha-textfield>
|
||||
|
||||
${this._params.domain === "script" &&
|
||||
this._visibleOptionals.includes("icon")
|
||||
? html`
|
||||
<ha-icon-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.icon"
|
||||
)}
|
||||
.value=${this._newIcon}
|
||||
@value-changed=${this._iconChanged}
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="fallback"
|
||||
domain=${this._params.domain}
|
||||
.hass=${this.hass}
|
||||
>
|
||||
</ha-domain-icon>
|
||||
</ha-icon-picker>
|
||||
`
|
||||
${this._params.description
|
||||
? html`<p>${this._params.description}</p>`
|
||||
: nothing}
|
||||
${this._visibleOptionals.includes("description")
|
||||
? html` <ha-textarea
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.description.label"
|
||||
)}
|
||||
.placeholder=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.description.placeholder"
|
||||
)}
|
||||
name="description"
|
||||
autogrow
|
||||
.value=${this._newDescription}
|
||||
@input=${this._valueChanged}
|
||||
></ha-textarea>`
|
||||
: nothing}
|
||||
${this._visibleOptionals.includes("category")
|
||||
? html` <ha-category-picker
|
||||
id="category"
|
||||
.hass=${this.hass}
|
||||
.scope=${this._params.domain}
|
||||
.value=${this._entryUpdates.category}
|
||||
@value-changed=${this._registryEntryChanged}
|
||||
></ha-category-picker>`
|
||||
: nothing}
|
||||
${this._visibleOptionals.includes("labels")
|
||||
? html` <ha-labels-picker
|
||||
id="labels"
|
||||
.hass=${this.hass}
|
||||
.value=${this._entryUpdates.labels}
|
||||
@value-changed=${this._registryEntryChanged}
|
||||
></ha-labels-picker>`
|
||||
: nothing}
|
||||
${this._visibleOptionals.includes("area")
|
||||
? html` <ha-area-picker
|
||||
id="area"
|
||||
.hass=${this.hass}
|
||||
.value=${this._entryUpdates.area}
|
||||
@value-changed=${this._registryEntryChanged}
|
||||
></ha-area-picker>`
|
||||
: nothing}
|
||||
|
||||
<ha-chip-set>
|
||||
${this._renderOptionalChip(
|
||||
"description",
|
||||
this.hass.localize(
|
||||
"ui.panel.config.automation.editor.dialog.add_description"
|
||||
)
|
||||
)}
|
||||
${this._params.domain === "script"
|
||||
? this._renderOptionalChip(
|
||||
"icon",
|
||||
this.hass.localize(
|
||||
"ui.panel.config.automation.editor.dialog.add_icon"
|
||||
)
|
||||
)
|
||||
: nothing}
|
||||
${this._renderOptionalChip(
|
||||
"area",
|
||||
this.hass.localize(
|
||||
"ui.panel.config.automation.editor.dialog.add_area"
|
||||
)
|
||||
)}
|
||||
${this._renderOptionalChip(
|
||||
"category",
|
||||
this.hass.localize(
|
||||
"ui.panel.config.automation.editor.dialog.add_category"
|
||||
)
|
||||
)}
|
||||
${this._renderOptionalChip(
|
||||
"labels",
|
||||
this.hass.localize(
|
||||
"ui.panel.config.automation.editor.dialog.add_labels"
|
||||
)
|
||||
)}
|
||||
</ha-chip-set>
|
||||
${this._renderInputs()} ${this._renderDiscard()}
|
||||
|
||||
<div slot="primaryAction">
|
||||
<mwc-button @click=${this.closeDialog}>
|
||||
@@ -247,7 +267,7 @@ class DialogAutomationRename extends LitElement implements HassDialog {
|
||||
</mwc-button>
|
||||
<mwc-button @click=${this._save}>
|
||||
${this.hass.localize(
|
||||
this._params.config.alias
|
||||
this._params.config.alias && !this._params.onDiscard
|
||||
? "ui.panel.config.automation.editor.rename"
|
||||
: "ui.panel.config.automation.editor.save"
|
||||
)}
|
||||
@@ -286,14 +306,19 @@ class DialogAutomationRename extends LitElement implements HassDialog {
|
||||
}
|
||||
}
|
||||
|
||||
private _save(): void {
|
||||
private _handleDiscard() {
|
||||
this._params.onDiscard?.();
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._newName) {
|
||||
this._error = "Name is required";
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._params.domain === "script") {
|
||||
this._params.updateConfig(
|
||||
await this._params.updateConfig(
|
||||
{
|
||||
...this._params.config,
|
||||
alias: this._newName,
|
||||
@@ -303,7 +328,7 @@ class DialogAutomationRename extends LitElement implements HassDialog {
|
||||
this._entryUpdates
|
||||
);
|
||||
} else {
|
||||
this._params.updateConfig(
|
||||
await this._params.updateConfig(
|
||||
{
|
||||
...this._params.config,
|
||||
alias: this._newName,
|
||||
@@ -351,6 +376,9 @@ class DialogAutomationRename extends LitElement implements HassDialog {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.destructive {
|
||||
--mdc-theme-primary: var(--error-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -358,6 +386,6 @@ class DialogAutomationRename extends LitElement implements HassDialog {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-dialog-automation-rename": DialogAutomationRename;
|
||||
"ha-dialog-automation-save": DialogAutomationSave;
|
||||
}
|
||||
}
|
@@ -3,13 +3,18 @@ import type { AutomationConfig } from "../../../../data/automation";
|
||||
import type { ScriptConfig } from "../../../../data/script";
|
||||
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
|
||||
|
||||
export const loadAutomationRenameDialog = () =>
|
||||
import("./dialog-automation-rename");
|
||||
export const loadAutomationSaveDialog = () =>
|
||||
import("./dialog-automation-save");
|
||||
|
||||
interface BaseRenameDialogParams {
|
||||
entityRegistryUpdate?: EntityRegistryUpdate;
|
||||
entityRegistryEntry?: EntityRegistryEntry;
|
||||
onClose: () => void;
|
||||
onDiscard?: () => void;
|
||||
saveText?: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
hideInputs?: boolean;
|
||||
}
|
||||
|
||||
export interface EntityRegistryUpdate {
|
||||
@@ -18,31 +23,35 @@ export interface EntityRegistryUpdate {
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface AutomationRenameDialogParams extends BaseRenameDialogParams {
|
||||
export interface AutomationSaveDialogParams extends BaseRenameDialogParams {
|
||||
config: AutomationConfig;
|
||||
domain: "automation";
|
||||
updateConfig: (
|
||||
config: AutomationConfig,
|
||||
entityRegistryUpdate: EntityRegistryUpdate
|
||||
) => void;
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ScriptRenameDialogParams extends BaseRenameDialogParams {
|
||||
export interface ScriptSaveDialogParams extends BaseRenameDialogParams {
|
||||
config: ScriptConfig;
|
||||
domain: "script";
|
||||
updateConfig: (
|
||||
config: ScriptConfig,
|
||||
entityRegistryUpdate: EntityRegistryUpdate
|
||||
) => void;
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export const showAutomationRenameDialog = (
|
||||
export type SaveDialogParams =
|
||||
| AutomationSaveDialogParams
|
||||
| ScriptSaveDialogParams;
|
||||
|
||||
export const showAutomationSaveDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: AutomationRenameDialogParams | ScriptRenameDialogParams
|
||||
dialogParams: SaveDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "ha-dialog-automation-rename",
|
||||
dialogImport: loadAutomationRenameDialog,
|
||||
dialogTag: "ha-dialog-automation-save",
|
||||
dialogImport: loadAutomationSaveDialog,
|
||||
dialogParams,
|
||||
});
|
||||
};
|
@@ -19,7 +19,7 @@ import {
|
||||
} from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consume } from "@lit-labs/context";
|
||||
@@ -70,8 +70,8 @@ import "../ha-config-section";
|
||||
import { showAutomationModeDialog } from "./automation-mode-dialog/show-dialog-automation-mode";
|
||||
import {
|
||||
type EntityRegistryUpdate,
|
||||
showAutomationRenameDialog,
|
||||
} from "./automation-rename-dialog/show-dialog-automation-rename";
|
||||
showAutomationSaveDialog,
|
||||
} from "./automation-save-dialog/show-dialog-automation-save";
|
||||
import "./blueprint-automation-editor";
|
||||
import "./manual-automation-editor";
|
||||
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
@@ -500,7 +500,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
|
||||
.label=${this.hass.localize("ui.panel.config.automation.editor.save")}
|
||||
.disabled=${this._saving}
|
||||
extended
|
||||
@click=${this._saveAutomation}
|
||||
@click=${this._handleSaveAutomation}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
|
||||
</ha-fab>
|
||||
@@ -743,20 +743,48 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
|
||||
}
|
||||
|
||||
private async _confirmUnsavedChanged(): Promise<boolean> {
|
||||
if (this._dirty) {
|
||||
return showConfirmationDialog(this, {
|
||||
title: this.hass!.localize(
|
||||
"ui.panel.config.automation.editor.unsaved_confirm_title"
|
||||
),
|
||||
text: this.hass!.localize(
|
||||
"ui.panel.config.automation.editor.unsaved_confirm_text"
|
||||
),
|
||||
confirmText: this.hass!.localize("ui.common.leave"),
|
||||
dismissText: this.hass!.localize("ui.common.stay"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!this._dirty) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
showAutomationSaveDialog(this, {
|
||||
config: this._config!,
|
||||
domain: "automation",
|
||||
updateConfig: async (config, entityRegistryUpdate) => {
|
||||
this._config = config;
|
||||
this._entityRegistryUpdate = entityRegistryUpdate;
|
||||
this._dirty = true;
|
||||
this.requestUpdate();
|
||||
|
||||
const id = this.automationId || String(Date.now());
|
||||
try {
|
||||
await this._saveAutomation(id);
|
||||
} catch (_err: any) {
|
||||
this.requestUpdate();
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(true);
|
||||
},
|
||||
onClose: () => resolve(false),
|
||||
onDiscard: () => resolve(true),
|
||||
entityRegistryUpdate: this._entityRegistryUpdate,
|
||||
entityRegistryEntry: this._registryEntry,
|
||||
title: this.hass.localize(
|
||||
this.automationId
|
||||
? "ui.panel.config.automation.editor.leave.unsaved_confirm_title"
|
||||
: "ui.panel.config.automation.editor.leave.unsaved_new_title"
|
||||
),
|
||||
description: this.hass.localize(
|
||||
this.automationId
|
||||
? "ui.panel.config.automation.editor.leave.unsaved_confirm_text"
|
||||
: "ui.panel.config.automation.editor.leave.unsaved_new_text"
|
||||
),
|
||||
hideInputs: this.automationId !== null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _backTapped = async () => {
|
||||
@@ -878,10 +906,10 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
|
||||
|
||||
private async _promptAutomationAlias(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
showAutomationRenameDialog(this, {
|
||||
showAutomationSaveDialog(this, {
|
||||
config: this._config!,
|
||||
domain: "automation",
|
||||
updateConfig: (config, entityRegistryUpdate) => {
|
||||
updateConfig: async (config, entityRegistryUpdate) => {
|
||||
this._config = config;
|
||||
this._entityRegistryUpdate = entityRegistryUpdate;
|
||||
this._dirty = true;
|
||||
@@ -910,7 +938,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
|
||||
});
|
||||
}
|
||||
|
||||
private async _saveAutomation(): Promise<void> {
|
||||
private async _handleSaveAutomation(): Promise<void> {
|
||||
if (this._yamlErrors) {
|
||||
showToast(this, {
|
||||
message: this._yamlErrors,
|
||||
@@ -926,6 +954,13 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
|
||||
}
|
||||
}
|
||||
|
||||
await this._saveAutomation(id);
|
||||
if (!this.automationId) {
|
||||
navigate(`/config/automation/edit/${id}`, { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async _saveAutomation(id): Promise<void> {
|
||||
this._saving = true;
|
||||
this._validationErrors = undefined;
|
||||
|
||||
@@ -990,10 +1025,6 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
|
||||
}
|
||||
|
||||
this._dirty = false;
|
||||
|
||||
if (!this.automationId) {
|
||||
navigate(`/config/automation/edit/${id}`, { replace: true });
|
||||
}
|
||||
} catch (errors: any) {
|
||||
this._errors = errors.body?.message || errors.error || errors.body;
|
||||
showToast(this, {
|
||||
@@ -1016,7 +1047,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
|
||||
|
||||
protected supportedShortcuts(): SupportedShortcuts {
|
||||
return {
|
||||
s: () => this._saveAutomation(),
|
||||
s: () => this._handleSaveAutomation(),
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -28,7 +28,7 @@ import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { formatShortDateTime } from "../../../common/datetime/format_date_time";
|
||||
import { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time";
|
||||
import { relativeTime } from "../../../common/datetime/relative_time";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
@@ -324,7 +324,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
const dayDifference = differenceInDays(now, date);
|
||||
return html`
|
||||
${dayDifference > 3
|
||||
? formatShortDateTime(date, locale, this.hass.config)
|
||||
? formatShortDateTimeWithConditionalYear(
|
||||
date,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)
|
||||
: relativeTime(date, locale)}
|
||||
`;
|
||||
},
|
||||
@@ -399,7 +403,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
(category) =>
|
||||
html`<ha-md-menu-item
|
||||
.value=${category.category_id}
|
||||
@click=${this._handleBulkCategory}
|
||||
.clickAction=${this._handleBulkCategory}
|
||||
>
|
||||
${category.icon
|
||||
? html`<ha-icon slot="start" .icon=${category.icon}></ha-icon>`
|
||||
@@ -407,7 +411,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
<div slot="headline">${category.name}</div>
|
||||
</ha-md-menu-item>`
|
||||
)}
|
||||
<ha-md-menu-item .value=${null} @click=${this._handleBulkCategory}>
|
||||
<ha-md-menu-item .value=${null} .clickAction=${this._handleBulkCategory}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.no_category"
|
||||
@@ -415,7 +419,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
|
||||
<ha-md-menu-item @click=${this._bulkCreateCategory}>
|
||||
<ha-md-menu-item .clickAction=${this._bulkCreateCategory}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.category.editor.add")}
|
||||
</div>
|
||||
@@ -452,7 +456,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
</ha-md-menu-item>`;
|
||||
})}
|
||||
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
|
||||
<ha-md-menu-item @click=${this._bulkCreateLabel}>
|
||||
<ha-md-menu-item .clickAction=${this._bulkCreateLabel}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.labels.add_label")}
|
||||
</div></ha-md-menu-item
|
||||
@@ -462,7 +466,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
(area) =>
|
||||
html`<ha-md-menu-item
|
||||
.value=${area.area_id}
|
||||
@click=${this._handleBulkArea}
|
||||
.clickAction=${this._handleBulkArea}
|
||||
>
|
||||
${area.icon
|
||||
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
|
||||
@@ -473,7 +477,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
<div slot="headline">${area.name}</div>
|
||||
</ha-md-menu-item>`
|
||||
)}
|
||||
<ha-md-menu-item .value=${null} @click=${this._handleBulkArea}>
|
||||
<ha-md-menu-item .value=${null} .clickAction=${this._handleBulkArea}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.picker.bulk_actions.no_area"
|
||||
@@ -481,7 +485,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
|
||||
<ha-md-menu-item @click=${this._bulkCreateArea}>
|
||||
<ha-md-menu-item .clickAction=${this._bulkCreateArea}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.picker.bulk_actions.add_area"
|
||||
@@ -538,7 +542,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
this.hass.localize,
|
||||
this.hass.locale
|
||||
)}
|
||||
.initialGroupColumn=${this._activeGrouping || "category"}
|
||||
.initialGroupColumn=${this._activeGrouping ?? "category"}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._activeSorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
@@ -756,7 +760,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
</ha-sub-menu>`
|
||||
: nothing
|
||||
}
|
||||
<ha-md-menu-item @click=${this._handleBulkEnable}>
|
||||
<ha-md-menu-item .clickAction=${this._handleBulkEnable}>
|
||||
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
@@ -764,7 +768,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
)}
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu-item @click=${this._handleBulkDisable}>
|
||||
<ha-md-menu-item .clickAction=${this._handleBulkDisable}>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiToggleSwitchOffOutline}
|
||||
@@ -1239,10 +1243,10 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleBulkCategory(ev) {
|
||||
const category = ev.currentTarget.value;
|
||||
private _handleBulkCategory = async (item) => {
|
||||
const category = item.value;
|
||||
this._bulkAddCategory(category);
|
||||
}
|
||||
};
|
||||
|
||||
private async _bulkAddCategory(category: string) {
|
||||
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
|
||||
@@ -1305,10 +1309,10 @@ ${rejected
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleBulkArea(ev) {
|
||||
const area = ev.currentTarget.value;
|
||||
private _handleBulkArea = (item) => {
|
||||
const area = item.value;
|
||||
this._bulkAddArea(area);
|
||||
}
|
||||
};
|
||||
|
||||
private async _bulkAddArea(area: string) {
|
||||
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
|
||||
@@ -1335,7 +1339,7 @@ ${rejected
|
||||
}
|
||||
}
|
||||
|
||||
private async _bulkCreateArea() {
|
||||
private _bulkCreateArea = async () => {
|
||||
showAreaRegistryDetailDialog(this, {
|
||||
createEntry: async (values) => {
|
||||
const area = await createAreaRegistryEntry(this.hass, values);
|
||||
@@ -1343,9 +1347,9 @@ ${rejected
|
||||
return area;
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private async _handleBulkEnable() {
|
||||
private _handleBulkEnable = async () => {
|
||||
const promises: Promise<ServiceCallResponse>[] = [];
|
||||
this._selected.forEach((entityId) => {
|
||||
promises.push(turnOnOffEntity(this.hass, entityId, true));
|
||||
@@ -1364,9 +1368,9 @@ ${rejected
|
||||
>`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private async _handleBulkDisable() {
|
||||
private _handleBulkDisable = async () => {
|
||||
const promises: Promise<ServiceCallResponse>[] = [];
|
||||
this._selected.forEach((entityId) => {
|
||||
promises.push(turnOnOffEntity(this.hass, entityId, false));
|
||||
@@ -1385,9 +1389,9 @@ ${rejected
|
||||
>`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private async _bulkCreateCategory() {
|
||||
private _bulkCreateCategory = async () => {
|
||||
showCategoryRegistryDetailDialog(this, {
|
||||
scope: "automation",
|
||||
createEntry: async (values) => {
|
||||
@@ -1400,9 +1404,9 @@ ${rejected
|
||||
return category;
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private _bulkCreateLabel() {
|
||||
private _bulkCreateLabel = () => {
|
||||
showLabelDetailDialog(this, {
|
||||
createEntry: async (values) => {
|
||||
const label = await createLabelRegistryEntry(this.hass, values);
|
||||
@@ -1410,14 +1414,14 @@ ${rejected
|
||||
return label;
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private _handleSortingChanged(ev: CustomEvent) {
|
||||
this._activeSorting = ev.detail;
|
||||
}
|
||||
|
||||
private _handleGroupingChanged(ev: CustomEvent) {
|
||||
this._activeGrouping = ev.detail.value;
|
||||
this._activeGrouping = ev.detail.value ?? "";
|
||||
}
|
||||
|
||||
private _handleCollapseChanged(ev: CustomEvent) {
|
||||
|
@@ -9,9 +9,11 @@ import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { TimeTrigger } from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { TriggerElement } from "../ha-automation-trigger-row";
|
||||
import { computeDomain } from "../../../../../common/entity/compute_domain";
|
||||
|
||||
const MODE_TIME = "time";
|
||||
const MODE_ENTITY = "entity";
|
||||
const VALID_DOMAINS = ["sensor", "input_datetime"];
|
||||
|
||||
@customElement("ha-automation-trigger-time")
|
||||
export class HaTimeTrigger extends LitElement implements TriggerElement {
|
||||
@@ -33,8 +35,7 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
inputMode: typeof MODE_TIME | typeof MODE_ENTITY,
|
||||
showOffset: boolean
|
||||
inputMode: typeof MODE_TIME | typeof MODE_ENTITY
|
||||
) =>
|
||||
[
|
||||
{
|
||||
@@ -65,16 +66,13 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
|
||||
entity: {
|
||||
filter: [
|
||||
{ domain: "input_datetime" },
|
||||
{ domain: "time" },
|
||||
{ domain: "sensor", device_class: "timestamp" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: "offset", selector: { text: {} } },
|
||||
] as const)),
|
||||
...(showOffset
|
||||
? ([{ name: "offset", selector: { text: {} } }] as const)
|
||||
: ([] as const)),
|
||||
] as const
|
||||
);
|
||||
|
||||
@@ -107,9 +105,7 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
|
||||
const entity =
|
||||
typeof at === "object"
|
||||
? at.entity_id
|
||||
: at?.startsWith("input_datetime.") ||
|
||||
at?.startsWith("time.") ||
|
||||
at?.startsWith("sensor.")
|
||||
: at && VALID_DOMAINS.includes(computeDomain(at))
|
||||
? at
|
||||
: undefined;
|
||||
const time = entity ? undefined : (at as string | undefined);
|
||||
@@ -132,9 +128,7 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
|
||||
}
|
||||
|
||||
const data = this._data(this._inputMode, at);
|
||||
const showOffset =
|
||||
data.mode === MODE_ENTITY && data.entity?.startsWith("sensor.");
|
||||
const schema = this._schema(this.hass.localize, data.mode, !!showOffset);
|
||||
const schema = this._schema(this.hass.localize, data.mode);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
@@ -157,9 +151,6 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
|
||||
delete newValue.offset;
|
||||
} else {
|
||||
delete newValue.time;
|
||||
if (!newValue.entity?.startsWith("sensor.")) {
|
||||
delete newValue.offset;
|
||||
}
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
|
@@ -33,6 +33,7 @@ export class HaTimePatternTrigger extends LitElement implements TriggerElement {
|
||||
.data=${this.trigger}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
@@ -50,6 +51,13 @@ export class HaTimePatternTrigger extends LitElement implements TriggerElement {
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time_pattern.${schema.name}`
|
||||
);
|
||||
|
||||
private _computeHelperCallback = (
|
||||
_schema: SchemaUnion<typeof SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time_pattern.help`
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
@@ -1,24 +1,28 @@
|
||||
import { mdiHarddisk, mdiNas } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { mdiCog, mdiDelete, mdiHarddisk, mdiNas } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../../../../common/entity/compute_domain";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import "../../../../../components/ha-switch";
|
||||
import type {
|
||||
BackupAgent,
|
||||
BackupAgentsConfig,
|
||||
} from "../../../../../data/backup";
|
||||
import {
|
||||
CLOUD_AGENT,
|
||||
compareAgents,
|
||||
computeBackupAgentName,
|
||||
fetchBackupAgentsInfo,
|
||||
isLocalAgent,
|
||||
isNetworkMountAgent,
|
||||
} from "../../../../../data/backup";
|
||||
import type { CloudStatus } from "../../../../../data/cloud";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { brandsUrl } from "../../../../../util/brands-url";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
|
||||
const DEFAULT_AGENTS = [];
|
||||
|
||||
@@ -28,23 +32,15 @@ class HaBackupConfigAgents extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public cloudStatus!: CloudStatus;
|
||||
|
||||
@state() private _agentIds: string[] = [];
|
||||
@property({ attribute: false }) public agents: BackupAgent[] = [];
|
||||
|
||||
@property({ attribute: false }) public agentsConfig?: BackupAgentsConfig;
|
||||
|
||||
@property({ type: Boolean, attribute: "show-settings" }) public showSettings =
|
||||
false;
|
||||
|
||||
@state() private value?: string[];
|
||||
|
||||
protected firstUpdated(_changedProperties: PropertyValues): void {
|
||||
super.firstUpdated(_changedProperties);
|
||||
this._fetchAgents();
|
||||
}
|
||||
|
||||
private async _fetchAgents() {
|
||||
const { agents } = await fetchBackupAgentsInfo(this.hass);
|
||||
this._agentIds = agents
|
||||
.map((agent) => agent.agent_id)
|
||||
.filter((id) => id !== CLOUD_AGENT || this.cloudStatus.logged_in)
|
||||
.sort(compareAgents);
|
||||
}
|
||||
|
||||
private get _value() {
|
||||
return this.value ?? DEFAULT_AGENTS;
|
||||
}
|
||||
@@ -60,6 +56,21 @@ class HaBackupConfigAgents extends LitElement {
|
||||
"ui.panel.config.backup.agents.cloud_agent_description"
|
||||
);
|
||||
}
|
||||
|
||||
const encryptionTurnedOff =
|
||||
this.agentsConfig?.[agentId]?.protected === false;
|
||||
|
||||
if (encryptionTurnedOff) {
|
||||
return html`
|
||||
<span class="dot warning"></span>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.agents.encryption_turned_off"
|
||||
)}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
if (isNetworkMountAgent(agentId)) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.backup.agents.network_mount_agent_description"
|
||||
@@ -68,74 +79,171 @@ class HaBackupConfigAgents extends LitElement {
|
||||
return "";
|
||||
}
|
||||
|
||||
protected render() {
|
||||
private _availableAgents = memoizeOne(
|
||||
(agents: BackupAgent[], cloudStatus: CloudStatus) =>
|
||||
agents.filter(
|
||||
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
|
||||
)
|
||||
);
|
||||
|
||||
private _unavailableAgents = memoizeOne(
|
||||
(
|
||||
agents: BackupAgent[],
|
||||
cloudStatus: CloudStatus,
|
||||
selectedAgentIds: string[]
|
||||
) => {
|
||||
const availableAgentIds = this._availableAgents(agents, cloudStatus).map(
|
||||
(agent) => agent.agent_id
|
||||
);
|
||||
|
||||
return selectedAgentIds
|
||||
.filter((agent) => !availableAgentIds.includes(agent))
|
||||
.map<BackupAgent>((id) => ({
|
||||
agent_id: id,
|
||||
name: id.split(".")[1] || id, // Use the id as name as it is not available in the list
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private _renderAgentIcon(agentId: string) {
|
||||
if (isLocalAgent(agentId)) {
|
||||
return html`
|
||||
<ha-svg-icon .path=${mdiHarddisk} slot="start"></ha-svg-icon>
|
||||
`;
|
||||
}
|
||||
|
||||
if (isNetworkMountAgent(agentId)) {
|
||||
return html`<ha-svg-icon .path=${mdiNas} slot="start"></ha-svg-icon>`;
|
||||
}
|
||||
|
||||
const domain = computeDomain(agentId);
|
||||
|
||||
return html`
|
||||
${this._agentIds.length > 0
|
||||
<img
|
||||
.src=${brandsUrl({
|
||||
domain,
|
||||
type: "icon",
|
||||
useFallback: true,
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
})}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=""
|
||||
slot="start"
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const availableAgents = this._availableAgents(
|
||||
this.agents,
|
||||
this.cloudStatus
|
||||
);
|
||||
const unavailableAgents = this._unavailableAgents(
|
||||
this.agents,
|
||||
this.cloudStatus,
|
||||
this._value
|
||||
);
|
||||
|
||||
const allAgents = [...availableAgents, ...unavailableAgents];
|
||||
|
||||
return html`
|
||||
${allAgents.length > 0
|
||||
? html`
|
||||
<ha-md-list>
|
||||
${this._agentIds.map((agentId) => {
|
||||
const domain = computeDomain(agentId);
|
||||
${availableAgents.map((agent) => {
|
||||
const agentId = agent.agent_id;
|
||||
const name = computeBackupAgentName(
|
||||
this.hass.localize,
|
||||
agentId,
|
||||
this._agentIds
|
||||
allAgents
|
||||
);
|
||||
const description = this._description(agentId);
|
||||
const noCloudSubscription =
|
||||
agentId === CLOUD_AGENT &&
|
||||
this.cloudStatus.logged_in &&
|
||||
!this.cloudStatus.active_subscription;
|
||||
|
||||
return html`
|
||||
<ha-md-list-item>
|
||||
${isLocalAgent(agentId)
|
||||
? html`
|
||||
<ha-svg-icon .path=${mdiHarddisk} slot="start">
|
||||
</ha-svg-icon>
|
||||
`
|
||||
: isNetworkMountAgent(agentId)
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
.path=${mdiNas}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: html`
|
||||
<img
|
||||
.src=${brandsUrl({
|
||||
domain,
|
||||
type: "icon",
|
||||
useFallback: true,
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
})}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=""
|
||||
slot="start"
|
||||
/>
|
||||
`}
|
||||
${this._renderAgentIcon(agentId)}
|
||||
<div slot="headline" class="name">${name}</div>
|
||||
${description
|
||||
? html`<div slot="supporting-text">${description}</div>`
|
||||
: nothing}
|
||||
${this.showSettings
|
||||
? html`
|
||||
<ha-icon-button
|
||||
id=${agentId}
|
||||
slot="end"
|
||||
path=${mdiCog}
|
||||
@click=${this._showAgentSettings}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing}
|
||||
<ha-switch
|
||||
slot="end"
|
||||
id=${agentId}
|
||||
.checked=${!noCloudSubscription &&
|
||||
this._value.includes(agentId)}
|
||||
.disabled=${noCloudSubscription}
|
||||
.checked=${this._value.includes(agentId)}
|
||||
.disabled=${noCloudSubscription &&
|
||||
!this._value.includes(agentId)}
|
||||
@change=${this._agentToggled}
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
})}
|
||||
${unavailableAgents.length > 0 && this.showSettings
|
||||
? html`
|
||||
<p class="heading">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.agents.unavailable_agents"
|
||||
)}
|
||||
</p>
|
||||
${unavailableAgents.map((agent) => {
|
||||
const agentId = agent.agent_id;
|
||||
const name = computeBackupAgentName(
|
||||
this.hass.localize,
|
||||
agentId,
|
||||
allAgents
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-md-list-item>
|
||||
${this._renderAgentIcon(agentId)}
|
||||
<div slot="headline" class="name">${name}</div>
|
||||
<ha-icon-button
|
||||
id=${agentId}
|
||||
slot="end"
|
||||
path=${mdiDelete}
|
||||
@click=${this._deleteAgent}
|
||||
></ha-icon-button>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
})}
|
||||
`
|
||||
: nothing}
|
||||
</ha-md-list>
|
||||
`
|
||||
: html`<p>
|
||||
${this.hass.localize("ui.panel.config.backup.agents.no_agents")}
|
||||
</p>`}
|
||||
: html`
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.config.backup.agents.no_agents")}
|
||||
</p>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private _showAgentSettings(ev): void {
|
||||
const agentId = ev.currentTarget.id;
|
||||
navigate(`/config/backup/location/${agentId}`);
|
||||
}
|
||||
|
||||
private _deleteAgent(ev): void {
|
||||
ev.stopPropagation();
|
||||
const agentId = ev.currentTarget.id;
|
||||
this.value = this._value.filter((agent) => agent !== agentId);
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
private _agentToggled(ev) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.currentTarget.checked;
|
||||
@@ -148,13 +256,7 @@ class HaBackupConfigAgents extends LitElement {
|
||||
}
|
||||
|
||||
// Ensure we don't have duplicates, agents exist in the list and cloud is logged in
|
||||
this.value = [...new Set(this.value)]
|
||||
.filter((agent) => this._agentIds.some((id) => id === agent))
|
||||
.filter(
|
||||
(id) =>
|
||||
id !== CLOUD_AGENT ||
|
||||
(this.cloudStatus.logged_in && this.cloudStatus.active_subscription)
|
||||
);
|
||||
this.value = [...new Set(this.value)];
|
||||
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
@@ -178,6 +280,25 @@ class HaBackupConfigAgents extends LitElement {
|
||||
--mdc-icon-size: 48px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
ha-md-list-item [slot="supporting-text"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
line-height: normal;
|
||||
}
|
||||
.dot {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--disabled-color);
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.dot.warning {
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
@@ -378,8 +378,9 @@ class HaBackupConfigData extends LitElement {
|
||||
}
|
||||
@media all and (max-width: 450px) {
|
||||
ha-md-select {
|
||||
min-width: 160px;
|
||||
width: 160px;
|
||||
min-width: 140px;
|
||||
width: 140px;
|
||||
--md-filled-field-content-space: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@@ -12,12 +12,22 @@ import type { HaMdSelect } from "../../../../../components/ha-md-select";
|
||||
import "../../../../../components/ha-md-select-option";
|
||||
import "../../../../../components/ha-md-textfield";
|
||||
import "../../../../../components/ha-switch";
|
||||
import type { BackupConfig } from "../../../../../data/backup";
|
||||
import type { BackupConfig, BackupDay } from "../../../../../data/backup";
|
||||
import {
|
||||
BackupScheduleState,
|
||||
getFormattedBackupTime,
|
||||
BACKUP_DAYS,
|
||||
BackupScheduleRecurrence,
|
||||
DEFAULT_OPTIMIZED_BACKUP_END_TIME,
|
||||
DEFAULT_OPTIMIZED_BACKUP_START_TIME,
|
||||
sortWeekdays,
|
||||
} from "../../../../../data/backup";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import "../../../../../components/ha-time-input";
|
||||
import "../../../../../components/ha-tip";
|
||||
import "../../../../../components/ha-expansion-panel";
|
||||
import "../../../../../components/ha-checkbox";
|
||||
import "../../../../../components/ha-formfield";
|
||||
import { formatTime } from "../../../../../common/datetime/format_time";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
|
||||
export type BackupConfigSchedule = Pick<BackupConfig, "schedule" | "retention">;
|
||||
|
||||
@@ -30,6 +40,11 @@ enum RetentionPreset {
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
enum BackupScheduleTime {
|
||||
DEFAULT = "default",
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
interface RetentionData {
|
||||
type: "copies" | "days";
|
||||
value: number;
|
||||
@@ -44,15 +59,10 @@ const RETENTION_PRESETS: Record<
|
||||
};
|
||||
|
||||
const SCHEDULE_OPTIONS = [
|
||||
BackupScheduleState.DAILY,
|
||||
BackupScheduleState.MONDAY,
|
||||
BackupScheduleState.TUESDAY,
|
||||
BackupScheduleState.WEDNESDAY,
|
||||
BackupScheduleState.THURSDAY,
|
||||
BackupScheduleState.FRIDAY,
|
||||
BackupScheduleState.SATURDAY,
|
||||
BackupScheduleState.SUNDAY,
|
||||
] as const satisfies BackupScheduleState[];
|
||||
BackupScheduleRecurrence.NEVER,
|
||||
BackupScheduleRecurrence.DAILY,
|
||||
BackupScheduleRecurrence.CUSTOM_DAYS,
|
||||
] as const satisfies BackupScheduleRecurrence[];
|
||||
|
||||
const RETENTION_PRESETS_OPTIONS = [
|
||||
RetentionPreset.COPIES_3,
|
||||
@@ -60,6 +70,11 @@ const RETENTION_PRESETS_OPTIONS = [
|
||||
RetentionPreset.CUSTOM,
|
||||
] as const satisfies RetentionPreset[];
|
||||
|
||||
const SCHEDULE_TIME_OPTIONS = [
|
||||
BackupScheduleTime.DEFAULT,
|
||||
BackupScheduleTime.CUSTOM,
|
||||
] as const satisfies BackupScheduleTime[];
|
||||
|
||||
const computeRetentionPreset = (
|
||||
data: RetentionData
|
||||
): RetentionPreset | undefined => {
|
||||
@@ -72,8 +87,10 @@ const computeRetentionPreset = (
|
||||
};
|
||||
|
||||
interface FormData {
|
||||
enabled: boolean;
|
||||
schedule: BackupScheduleState;
|
||||
recurrence: BackupScheduleRecurrence;
|
||||
time_option: BackupScheduleTime;
|
||||
time?: string | null;
|
||||
days: BackupDay[];
|
||||
retention: {
|
||||
type: "copies" | "days";
|
||||
value: number;
|
||||
@@ -81,8 +98,9 @@ interface FormData {
|
||||
}
|
||||
|
||||
const INITIAL_FORM_DATA: FormData = {
|
||||
enabled: false,
|
||||
schedule: BackupScheduleState.NEVER,
|
||||
recurrence: BackupScheduleRecurrence.NEVER,
|
||||
time_option: BackupScheduleTime.DEFAULT,
|
||||
days: [],
|
||||
retention: {
|
||||
type: "copies",
|
||||
value: 3,
|
||||
@@ -114,8 +132,15 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
const config = value;
|
||||
|
||||
return {
|
||||
enabled: config.schedule.state !== BackupScheduleState.NEVER,
|
||||
schedule: config.schedule.state,
|
||||
recurrence: config.schedule.recurrence,
|
||||
time_option: config.schedule.time
|
||||
? BackupScheduleTime.CUSTOM
|
||||
: BackupScheduleTime.DEFAULT,
|
||||
time: config.schedule.time,
|
||||
days:
|
||||
config.schedule.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS
|
||||
? config.schedule.days
|
||||
: [],
|
||||
retention: {
|
||||
type: config.retention.days != null ? "days" : "copies",
|
||||
value: config.retention.days ?? config.retention.copies ?? 3,
|
||||
@@ -125,8 +150,14 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
|
||||
private _setData(data: FormData) {
|
||||
this.value = {
|
||||
...this.value,
|
||||
schedule: {
|
||||
state: data.enabled ? data.schedule : BackupScheduleState.NEVER,
|
||||
recurrence: data.recurrence,
|
||||
time: data.time_option === BackupScheduleTime.CUSTOM ? data.time : null,
|
||||
days:
|
||||
data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS
|
||||
? data.days
|
||||
: [],
|
||||
},
|
||||
retention:
|
||||
data.retention.type === "days"
|
||||
@@ -140,49 +171,113 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
protected render() {
|
||||
const data = this._getData(this.value);
|
||||
|
||||
const time = getFormattedBackupTime(this.hass.locale, this.hass.config);
|
||||
|
||||
return html`
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.use_automatic_backups"
|
||||
"ui.panel.config.backup.schedule.schedule"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.schedule_description"
|
||||
)}
|
||||
</span>
|
||||
|
||||
<ha-switch
|
||||
<ha-md-select
|
||||
slot="end"
|
||||
@change=${this._enabledChanged}
|
||||
.checked=${data.enabled}
|
||||
></ha-switch>
|
||||
@change=${this._scheduleChanged}
|
||||
.value=${data.recurrence}
|
||||
>
|
||||
${SCHEDULE_OPTIONS.map(
|
||||
(option) => html`
|
||||
<ha-md-select-option .value=${option}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.schedule.schedule_options.${option}`
|
||||
)}
|
||||
</div>
|
||||
</ha-md-select-option>
|
||||
`
|
||||
)}
|
||||
</ha-md-select>
|
||||
</ha-md-list-item>
|
||||
${data.enabled
|
||||
${data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS
|
||||
? html`<ha-expansion-panel
|
||||
expanded
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.custom_schedule"
|
||||
)}
|
||||
outlined
|
||||
>
|
||||
<ha-md-list-item class="days">
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.backup_every"
|
||||
)}
|
||||
</span>
|
||||
<div slot="end">
|
||||
${BACKUP_DAYS.map(
|
||||
(day) => html`
|
||||
<div>
|
||||
<ha-formfield
|
||||
.label=${this.hass.localize(`ui.panel.config.backup.overview.settings.weekdays.${day}`)}
|
||||
>
|
||||
<ha-checkbox
|
||||
@change=${this._daysChanged}
|
||||
.checked=${data.days.includes(day)}
|
||||
.value=${day}
|
||||
>
|
||||
</ha-checkbox>
|
||||
</span>
|
||||
</ha-formfield>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-md-list-item>
|
||||
</ha-expansion-panel>`
|
||||
: nothing}
|
||||
${data.recurrence === BackupScheduleRecurrence.DAILY ||
|
||||
(data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS &&
|
||||
data.days.length > 0)
|
||||
? html`
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.schedule"
|
||||
)}
|
||||
</span>
|
||||
"ui.panel.config.backup.schedule.time"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.schedule_description"
|
||||
"ui.panel.config.backup.schedule.schedule_time_description",
|
||||
{
|
||||
time_range_start: formatTime(
|
||||
DEFAULT_OPTIMIZED_BACKUP_START_TIME,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
time_range_end: formatTime(
|
||||
DEFAULT_OPTIMIZED_BACKUP_END_TIME,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
|
||||
<ha-md-select
|
||||
slot="end"
|
||||
@change=${this._scheduleChanged}
|
||||
.value=${data.schedule}
|
||||
@change=${this._scheduleTimeChanged}
|
||||
.value=${data.time_option}
|
||||
>
|
||||
${SCHEDULE_OPTIONS.map(
|
||||
${SCHEDULE_TIME_OPTIONS.map(
|
||||
(option) => html`
|
||||
<ha-md-select-option .value=${option}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.schedule.schedule_options.${option}`,
|
||||
{ time }
|
||||
`ui.panel.config.backup.schedule.time_options.${option}`
|
||||
)}
|
||||
</div>
|
||||
</ha-md-select-option>
|
||||
@@ -190,100 +285,197 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
)}
|
||||
</ha-md-select>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.schedule.retention`
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.schedule.retention_description`
|
||||
)}
|
||||
</span>
|
||||
<ha-md-select
|
||||
slot="end"
|
||||
@change=${this._retentionPresetChanged}
|
||||
.value=${this._retentionPreset}
|
||||
>
|
||||
${RETENTION_PRESETS_OPTIONS.map(
|
||||
(option) => html`
|
||||
<ha-md-select-option .value=${option}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.schedule.retention_presets.${option}`
|
||||
)}
|
||||
</div>
|
||||
</ha-md-select-option>
|
||||
`
|
||||
)}
|
||||
</ha-md-select>
|
||||
</ha-md-list-item>
|
||||
${this._retentionPreset === RetentionPreset.CUSTOM
|
||||
? html`
|
||||
${data.time_option === BackupScheduleTime.CUSTOM
|
||||
? html`<ha-expansion-panel
|
||||
expanded
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.custom_time"
|
||||
)}
|
||||
outlined
|
||||
>
|
||||
<ha-md-list-item>
|
||||
<ha-md-textfield
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.custom_time_label"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.custom_time_description",
|
||||
{
|
||||
time: formatTime(
|
||||
DEFAULT_OPTIMIZED_BACKUP_START_TIME,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
<ha-time-input
|
||||
slot="end"
|
||||
@change=${this._retentionValueChanged}
|
||||
.value=${data.retention.value}
|
||||
id="value"
|
||||
type="number"
|
||||
.min=${MIN_VALUE}
|
||||
.max=${MAX_VALUE}
|
||||
step="1"
|
||||
@value-changed=${this._timeChanged}
|
||||
.value=${data.time ?? undefined}
|
||||
.locale=${this.hass.locale}
|
||||
>
|
||||
</ha-md-textfield>
|
||||
<ha-md-select
|
||||
slot="end"
|
||||
@change=${this._retentionTypeChanged}
|
||||
.value=${data.retention.type}
|
||||
id="type"
|
||||
>
|
||||
<ha-md-select-option value="days">
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.retention_units.days"
|
||||
)}
|
||||
</div>
|
||||
</ha-md-select-option>
|
||||
<ha-md-select-option value="copies">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.retention_units.copies"
|
||||
)}
|
||||
</ha-md-select-option>
|
||||
</ha-md-select>
|
||||
</ha-time-input>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
</ha-expansion-panel>`
|
||||
: nothing}
|
||||
`
|
||||
: nothing}
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(`ui.panel.config.backup.schedule.retention`)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.schedule.retention_description`
|
||||
)}
|
||||
</span>
|
||||
<ha-md-select
|
||||
slot="end"
|
||||
@change=${this._retentionPresetChanged}
|
||||
.value=${this._retentionPreset ?? ""}
|
||||
>
|
||||
${RETENTION_PRESETS_OPTIONS.map(
|
||||
(option) => html`
|
||||
<ha-md-select-option .value=${option}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.schedule.retention_presets.${option}`
|
||||
)}
|
||||
</div>
|
||||
</ha-md-select-option>
|
||||
`
|
||||
)}
|
||||
</ha-md-select>
|
||||
</ha-md-list-item>
|
||||
|
||||
${this._retentionPreset === RetentionPreset.CUSTOM
|
||||
? html`<ha-expansion-panel
|
||||
expanded
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.custom_retention"
|
||||
)}
|
||||
outlined
|
||||
>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.custom_retention_label"
|
||||
)}
|
||||
</span>
|
||||
<ha-md-textfield
|
||||
slot="end"
|
||||
@change=${this._retentionValueChanged}
|
||||
.value=${data.retention.value.toString()}
|
||||
id="value"
|
||||
type="number"
|
||||
.min=${MIN_VALUE.toString()}
|
||||
.max=${MAX_VALUE.toString()}
|
||||
step="1"
|
||||
>
|
||||
</ha-md-textfield>
|
||||
<ha-md-select
|
||||
slot="end"
|
||||
@change=${this._retentionTypeChanged}
|
||||
.value=${data.retention.type}
|
||||
id="type"
|
||||
>
|
||||
<ha-md-select-option value="days">
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.retention_units.days"
|
||||
)}
|
||||
</div>
|
||||
</ha-md-select-option>
|
||||
<ha-md-select-option value="copies">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.retention_units.copies"
|
||||
)}
|
||||
</ha-md-select-option>
|
||||
</ha-md-select>
|
||||
</ha-md-list-item></ha-expansion-panel
|
||||
> `
|
||||
: nothing}
|
||||
<ha-tip .hass=${this.hass}
|
||||
>${this.hass.localize("ui.panel.config.backup.schedule.tip", {
|
||||
backup_create: html`<a
|
||||
href=${documentationUrl(
|
||||
this.hass,
|
||||
"/integrations/backup/#action-backupcreate_automatic"
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>backup.create_automatic</a
|
||||
>`,
|
||||
})}</ha-tip
|
||||
>
|
||||
</ha-md-list>
|
||||
`;
|
||||
}
|
||||
|
||||
private _enabledChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
const target = ev.currentTarget as HaCheckbox;
|
||||
const data = this._getData(this.value);
|
||||
this._setData({
|
||||
...data,
|
||||
enabled: target.checked,
|
||||
schedule: target.checked
|
||||
? BackupScheduleState.DAILY
|
||||
: BackupScheduleState.NEVER,
|
||||
});
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
private _scheduleChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
const target = ev.currentTarget as HaMdSelect;
|
||||
const data = this._getData(this.value);
|
||||
let days = [...data.days];
|
||||
|
||||
if (
|
||||
target.value === BackupScheduleRecurrence.CUSTOM_DAYS &&
|
||||
data.days.length === 0
|
||||
) {
|
||||
days = [...BACKUP_DAYS];
|
||||
}
|
||||
|
||||
this._setData({
|
||||
...data,
|
||||
schedule: target.value as BackupScheduleState,
|
||||
recurrence: target.value as BackupScheduleRecurrence,
|
||||
days,
|
||||
});
|
||||
}
|
||||
|
||||
private _scheduleTimeChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
const target = ev.currentTarget as HaMdSelect;
|
||||
const data = this._getData(this.value);
|
||||
this._setData({
|
||||
...data,
|
||||
time_option: target.value as BackupScheduleTime,
|
||||
time: target.value === BackupScheduleTime.CUSTOM ? "04:45:00" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _timeChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
const data = this._getData(this.value);
|
||||
|
||||
this._setData({
|
||||
...data,
|
||||
time: ev.detail.value,
|
||||
});
|
||||
}
|
||||
|
||||
private _daysChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const target = ev.currentTarget as HaCheckbox;
|
||||
const value = target.value as BackupDay;
|
||||
const data = this._getData(this.value);
|
||||
const days = [...data.days];
|
||||
|
||||
if (target.checked && !data.days.includes(value)) {
|
||||
days.push(value);
|
||||
} else if (!target.checked && data.days.includes(value)) {
|
||||
days.splice(days.indexOf(value), 1);
|
||||
}
|
||||
|
||||
sortWeekdays(days);
|
||||
|
||||
this._setData({
|
||||
...data,
|
||||
days,
|
||||
});
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
private _retentionPresetChanged(ev) {
|
||||
@@ -304,8 +496,6 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
retention: RETENTION_PRESETS[value],
|
||||
});
|
||||
}
|
||||
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
private _retentionValueChanged(ev) {
|
||||
@@ -321,8 +511,6 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
value: clamped,
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
private _retentionTypeChanged(ev) {
|
||||
@@ -338,8 +526,6 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
type: value,
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -354,9 +540,19 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
ha-md-select {
|
||||
min-width: 210px;
|
||||
}
|
||||
ha-time-input {
|
||||
min-width: 194px;
|
||||
--time-input-flex: 1;
|
||||
}
|
||||
@media all and (max-width: 450px) {
|
||||
ha-md-select {
|
||||
min-width: 160px;
|
||||
width: 160px;
|
||||
--md-filled-field-content-space: 0;
|
||||
}
|
||||
ha-time-input {
|
||||
min-width: 145px;
|
||||
width: 145px;
|
||||
}
|
||||
}
|
||||
ha-md-textfield#value {
|
||||
@@ -365,6 +561,31 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
ha-md-select#type {
|
||||
min-width: 100px;
|
||||
}
|
||||
@media all and (max-width: 450px) {
|
||||
ha-md-textfield#value {
|
||||
min-width: 60px;
|
||||
margin: 0 -8px;
|
||||
}
|
||||
ha-md-select#type {
|
||||
min-width: 120px;
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--expansion-panel-summary-padding: 0 16px;
|
||||
--expansion-panel-content-padding: 0 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-tip {
|
||||
text-align: unset;
|
||||
margin: 16px 0;
|
||||
}
|
||||
ha-md-list-item.days {
|
||||
--md-item-align-items: flex-start;
|
||||
}
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
@@ -7,6 +7,7 @@ import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import "../../../../components/ha-checkbox";
|
||||
import "../../../../components/ha-formfield";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { BackupAgent } from "../../../../data/backup";
|
||||
import {
|
||||
computeBackupAgentName,
|
||||
isLocalAgent,
|
||||
@@ -24,7 +25,7 @@ class HaBackupAgentsPicker extends LitElement {
|
||||
public disabled = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public agentIds!: string[];
|
||||
public agents!: BackupAgent[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public disabledAgentIds?: string[];
|
||||
@@ -35,30 +36,30 @@ class HaBackupAgentsPicker extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<div class="agents">
|
||||
${this.agentIds.map((agent) => this._renderAgent(agent))}
|
||||
${this.agents.map((agent) => this._renderAgent(agent))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAgent(agentId: string) {
|
||||
const domain = computeDomain(agentId);
|
||||
private _renderAgent(agent: BackupAgent) {
|
||||
const domain = computeDomain(agent.agent_id);
|
||||
const name = computeBackupAgentName(
|
||||
this.hass.localize,
|
||||
agentId,
|
||||
this.agentIds
|
||||
agent.agent_id,
|
||||
this.agents
|
||||
);
|
||||
|
||||
const disabled =
|
||||
this.disabled || this.disabledAgentIds?.includes(agentId) || false;
|
||||
this.disabled || this.disabledAgentIds?.includes(agent.agent_id) || false;
|
||||
|
||||
return html`
|
||||
<ha-formfield>
|
||||
<span class="label ${classMap({ disabled })}" slot="label">
|
||||
${isLocalAgent(agentId)
|
||||
${isLocalAgent(agent.agent_id)
|
||||
? html`
|
||||
<ha-svg-icon .path=${mdiHarddisk} slot="start"> </ha-svg-icon>
|
||||
`
|
||||
: isNetworkMountAgent(agentId)
|
||||
: isNetworkMountAgent(agent.agent_id)
|
||||
? html` <ha-svg-icon .path=${mdiNas} slot="start"></ha-svg-icon> `
|
||||
: html`
|
||||
<img
|
||||
@@ -77,8 +78,8 @@ class HaBackupAgentsPicker extends LitElement {
|
||||
${name}
|
||||
</span>
|
||||
<ha-checkbox
|
||||
.checked=${this.value.includes(agentId)}
|
||||
.value=${agentId}
|
||||
.checked=${this.value.includes(agent.agent_id)}
|
||||
.value=${agent.agent_id}
|
||||
.disabled=${disabled}
|
||||
@change=${this._checkboxChanged}
|
||||
></ha-checkbox>
|
||||
|
@@ -4,6 +4,7 @@ import {
|
||||
mdiFolder,
|
||||
mdiPlayBoxMultiple,
|
||||
mdiPuzzle,
|
||||
mdiShieldCheck,
|
||||
} from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -36,6 +37,7 @@ const ITEM_ICONS = {
|
||||
database: mdiChartBox,
|
||||
media: mdiPlayBoxMultiple,
|
||||
share: mdiFolder,
|
||||
ssl: mdiShieldCheck,
|
||||
};
|
||||
|
||||
interface SelectedItems {
|
||||
@@ -104,6 +106,8 @@ export class HaBackupDataPicker extends LitElement {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.backup.data_picker.share_folder"
|
||||
);
|
||||
case "ssl":
|
||||
return this.hass.localize("ui.panel.config.backup.data_picker.ssl");
|
||||
case "addons/local":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.backup.data_picker.local_addons"
|
||||
@@ -167,15 +171,14 @@ export class HaBackupDataPicker extends LitElement {
|
||||
})
|
||||
);
|
||||
|
||||
private _itemChanged(ev: Event) {
|
||||
private _homeassistantChanged(ev: Event) {
|
||||
const itemValues = this._parseValue(this.value);
|
||||
|
||||
const checkbox = ev.currentTarget as HaCheckbox;
|
||||
const section = (checkbox as any).section;
|
||||
if (checkbox.checked) {
|
||||
itemValues[section].push(checkbox.id);
|
||||
itemValues.homeassistant.push(checkbox.id);
|
||||
} else {
|
||||
itemValues[section] = itemValues[section].filter(
|
||||
itemValues.homeassistant = itemValues.homeassistant.filter(
|
||||
(id) => id !== checkbox.id
|
||||
);
|
||||
}
|
||||
@@ -262,8 +265,7 @@ export class HaBackupDataPicker extends LitElement {
|
||||
.checked=${selectedItems.homeassistant.includes(
|
||||
item.id
|
||||
)}
|
||||
.section=${"homeassistant"}
|
||||
@change=${this._itemChanged}
|
||||
@change=${this._homeassistantChanged}
|
||||
></ha-checkbox>
|
||||
</ha-formfield>
|
||||
`
|
||||
@@ -279,7 +281,7 @@ export class HaBackupDataPicker extends LitElement {
|
||||
<ha-backup-formfield-label
|
||||
slot="label"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.backup.data_picker.local_addons"
|
||||
"ui.panel.config.backup.data_picker.addons"
|
||||
)}
|
||||
.iconPath=${mdiPuzzle}
|
||||
>
|
||||
|
@@ -1,14 +1,20 @@
|
||||
import { mdiCalendarSync, mdiGestureTap } from "@mdi/js";
|
||||
import { mdiCalendarSync, mdiGestureTap, mdiPuzzle } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import type { BackupContent } from "../../../../../data/backup";
|
||||
import type { BackupContent, BackupType } from "../../../../../data/backup";
|
||||
import {
|
||||
computeBackupSize,
|
||||
computeBackupType,
|
||||
getBackupTypes,
|
||||
} from "../../../../../data/backup";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { bytesToString } from "../../../../../util/bytes-to-string";
|
||||
@@ -18,11 +24,17 @@ interface BackupStats {
|
||||
size: number;
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<BackupType, string> = {
|
||||
automatic: mdiCalendarSync,
|
||||
manual: mdiGestureTap,
|
||||
addon_update: mdiPuzzle,
|
||||
};
|
||||
|
||||
const computeBackupStats = (backups: BackupContent[]): BackupStats =>
|
||||
backups.reduce(
|
||||
(stats, backup) => {
|
||||
stats.count++;
|
||||
stats.size += backup.size;
|
||||
stats.size += computeBackupSize(backup);
|
||||
return stats;
|
||||
},
|
||||
{ count: 0, size: 0 }
|
||||
@@ -34,23 +46,22 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public backups: BackupContent[] = [];
|
||||
|
||||
private _automaticStats = memoizeOne((backups: BackupContent[]) => {
|
||||
const automaticBackups = backups.filter(
|
||||
(backup) => backup.with_automatic_settings
|
||||
);
|
||||
return computeBackupStats(automaticBackups);
|
||||
});
|
||||
|
||||
private _manualStats = memoizeOne((backups: BackupContent[]) => {
|
||||
const manualBackups = backups.filter(
|
||||
(backup) => !backup.with_automatic_settings
|
||||
);
|
||||
return computeBackupStats(manualBackups);
|
||||
});
|
||||
private _stats = memoizeOne(
|
||||
(
|
||||
backups: BackupContent[],
|
||||
isHassio: boolean
|
||||
): [BackupType, BackupStats][] =>
|
||||
getBackupTypes(isHassio).map((type) => {
|
||||
const backupsOfType = backups.filter(
|
||||
(backup) => computeBackupType(backup, isHassio) === type
|
||||
);
|
||||
return [type, computeBackupStats(backupsOfType)] as const;
|
||||
})
|
||||
);
|
||||
|
||||
render() {
|
||||
const automaticStats = this._automaticStats(this.backups);
|
||||
const manualStats = this._manualStats(this.backups);
|
||||
const isHassio = isComponentLoaded(this.hass, "hassio");
|
||||
const stats = this._stats(this.backups, isHassio);
|
||||
|
||||
return html`
|
||||
<ha-card class="my-backups">
|
||||
@@ -59,44 +70,32 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
href="/config/backup/backups?type=automatic"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiCalendarSync}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.overview.backups.automatic",
|
||||
{ count: automaticStats.count }
|
||||
)}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.overview.backups.total_size",
|
||||
{ size: bytesToString(automaticStats.size, 1) }
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
href="/config/backup/backups?type=manual"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiGestureTap}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.overview.backups.manual",
|
||||
{ count: manualStats.count }
|
||||
)}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.overview.backups.total_size",
|
||||
{ size: bytesToString(manualStats.size, 1) }
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
${stats.map(
|
||||
([type, { count, size }]) => html`
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
href="/config/backup/backups?type=${type}"
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${TYPE_ICONS[type]}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.overview.backups.${type}`,
|
||||
{ count }
|
||||
)}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.overview.backups.total_size",
|
||||
{ size: bytesToString(size) }
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
)}
|
||||
</ha-md-list>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
|
@@ -9,9 +9,9 @@ import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import type { BackupConfig } from "../../../../../data/backup";
|
||||
import type { BackupAgent, BackupConfig } from "../../../../../data/backup";
|
||||
import {
|
||||
BackupScheduleState,
|
||||
BackupScheduleRecurrence,
|
||||
computeBackupAgentName,
|
||||
getFormattedBackupTime,
|
||||
isLocalAgent,
|
||||
@@ -25,30 +25,95 @@ class HaBackupBackupsSummary extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public config!: BackupConfig;
|
||||
|
||||
@property({ attribute: false }) public agents!: BackupAgent[];
|
||||
|
||||
private _configure() {
|
||||
navigate("/config/backup/settings");
|
||||
}
|
||||
|
||||
private _scheduleDescription(config: BackupConfig): string {
|
||||
const { copies, days } = config.retention;
|
||||
const { state: schedule } = config.schedule;
|
||||
const { recurrence } = config.schedule;
|
||||
|
||||
if (schedule === BackupScheduleState.NEVER) {
|
||||
if (recurrence === BackupScheduleRecurrence.NEVER) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.backup.overview.settings.schedule_never"
|
||||
);
|
||||
}
|
||||
|
||||
const time = getFormattedBackupTime(this.hass.locale, this.hass.config);
|
||||
const time: string | undefined | null =
|
||||
this.config.schedule.time &&
|
||||
getFormattedBackupTime(
|
||||
this.hass.locale,
|
||||
this.hass.config,
|
||||
this.config.schedule.time
|
||||
);
|
||||
|
||||
const scheduleText = this.hass.localize(
|
||||
`ui.panel.config.backup.overview.settings.schedule_${schedule}`,
|
||||
{ time }
|
||||
let scheduleText = this.hass.localize(
|
||||
"ui.panel.config.backup.overview.settings.schedule_never"
|
||||
);
|
||||
|
||||
const configDays = this.config.schedule.days;
|
||||
|
||||
if (
|
||||
this.config.schedule.recurrence === BackupScheduleRecurrence.DAILY ||
|
||||
(this.config.schedule.recurrence ===
|
||||
BackupScheduleRecurrence.CUSTOM_DAYS &&
|
||||
configDays.length === 7)
|
||||
) {
|
||||
scheduleText = this.hass.localize(
|
||||
`ui.panel.config.backup.overview.settings.schedule_${!this.config.schedule.time ? "optimized_" : ""}daily`,
|
||||
{
|
||||
time,
|
||||
}
|
||||
);
|
||||
} else if (
|
||||
this.config.schedule.recurrence ===
|
||||
BackupScheduleRecurrence.CUSTOM_DAYS &&
|
||||
configDays.length !== 0
|
||||
) {
|
||||
if (
|
||||
configDays.length === 2 &&
|
||||
configDays.includes("sat") &&
|
||||
configDays.includes("sun")
|
||||
) {
|
||||
scheduleText = this.hass.localize(
|
||||
`ui.panel.config.backup.overview.settings.schedule_${!this.config.schedule.time ? "optimized_" : ""}weekend`,
|
||||
{
|
||||
time,
|
||||
}
|
||||
);
|
||||
} else if (
|
||||
configDays.length === 5 &&
|
||||
!configDays.includes("sat") &&
|
||||
!configDays.includes("sun")
|
||||
) {
|
||||
scheduleText = this.hass.localize(
|
||||
`ui.panel.config.backup.overview.settings.schedule_${!this.config.schedule.time ? "optimized_" : ""}weekdays`,
|
||||
{
|
||||
time,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
scheduleText = this.hass.localize(
|
||||
`ui.panel.config.backup.overview.settings.schedule_${!this.config.schedule.time ? "optimized_" : ""}days`,
|
||||
{
|
||||
count: configDays.length,
|
||||
days: configDays
|
||||
.map((dayCode) =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.backup.overview.settings.${configDays.length > 2 ? "short_weekdays" : "weekdays"}.${dayCode}`
|
||||
)
|
||||
)
|
||||
.join(", "),
|
||||
time,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let copiesText = this.hass.localize(
|
||||
`ui.panel.config.backup.overview.settings.schedule_copies_all`,
|
||||
{ time }
|
||||
`ui.panel.config.backup.overview.settings.schedule_copies_all`
|
||||
);
|
||||
if (copies) {
|
||||
copiesText = this.hass.localize(
|
||||
@@ -97,7 +162,7 @@ class HaBackupBackupsSummary extends LitElement {
|
||||
const name = computeBackupAgentName(
|
||||
this.hass.localize,
|
||||
offsiteLocations[0],
|
||||
offsiteLocations
|
||||
this.agents
|
||||
);
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.backup.overview.settings.locations_one",
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { mdiBackupRestore, mdiCalendar } from "@mdi/js";
|
||||
import { addHours, differenceInDays } from "date-fns";
|
||||
import { mdiBackupRestore, mdiCalendar, mdiInformation } from "@mdi/js";
|
||||
import { addHours, differenceInDays, isToday, isTomorrow } from "date-fns";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { relativeTime } from "../../../../../common/datetime/relative_time";
|
||||
@@ -10,14 +10,20 @@ import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import type { BackupConfig, BackupContent } from "../../../../../data/backup";
|
||||
import {
|
||||
BackupScheduleState,
|
||||
BackupScheduleRecurrence,
|
||||
getFormattedBackupTime,
|
||||
} from "../../../../../data/backup";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import "../ha-backup-summary-card";
|
||||
import {
|
||||
formatDate,
|
||||
formatDateWeekday,
|
||||
} from "../../../../../common/datetime/format_date";
|
||||
import { showAlertDialog } from "../../../../lovelace/custom-card-helpers";
|
||||
|
||||
const OVERDUE_MARGIN_HOURS = 3;
|
||||
|
||||
@@ -76,16 +82,6 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
|
||||
const lastBackup = this._lastBackup(this.backups);
|
||||
|
||||
const backupTime = getFormattedBackupTime(
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
|
||||
const nextBackupDescription = this.hass.localize(
|
||||
`ui.panel.config.backup.overview.summary.next_backup_description.${this.config.schedule.state}`,
|
||||
{ time: backupTime }
|
||||
);
|
||||
|
||||
const lastAttemptDate = this.config.last_attempted_automatic_backup
|
||||
? new Date(this.config.last_attempted_automatic_backup)
|
||||
: new Date(0);
|
||||
@@ -94,6 +90,49 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
? new Date(this.config.last_completed_automatic_backup)
|
||||
: new Date(0);
|
||||
|
||||
const nextAutomaticDate = this.config.next_automatic_backup
|
||||
? new Date(this.config.next_automatic_backup)
|
||||
: undefined;
|
||||
|
||||
const backupTime = getFormattedBackupTime(
|
||||
this.hass.locale,
|
||||
this.hass.config,
|
||||
nextAutomaticDate || this.config.schedule.time
|
||||
);
|
||||
|
||||
const showAdditionalBackupDescription =
|
||||
this.config.next_automatic_backup_additional;
|
||||
|
||||
const nextBackupDescription =
|
||||
this.config.schedule.recurrence === BackupScheduleRecurrence.NEVER ||
|
||||
(this.config.schedule.recurrence ===
|
||||
BackupScheduleRecurrence.CUSTOM_DAYS &&
|
||||
this.config.schedule.days.length === 0)
|
||||
? this.hass.localize(
|
||||
`ui.panel.config.backup.overview.summary.no_automatic_backup`
|
||||
)
|
||||
: nextAutomaticDate
|
||||
? this.hass.localize(
|
||||
`ui.panel.config.backup.overview.summary.next_automatic_backup`,
|
||||
{
|
||||
day: isTomorrow(nextAutomaticDate)
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.backup.overview.summary.tomorrow"
|
||||
)
|
||||
: isToday(nextAutomaticDate)
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.backup.overview.summary.today"
|
||||
)
|
||||
: formatDateWeekday(
|
||||
nextAutomaticDate,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
time: backupTime,
|
||||
}
|
||||
)
|
||||
: "";
|
||||
|
||||
// If last attempt is after last completed backup, show error
|
||||
if (lastAttemptDate > lastCompletedDate) {
|
||||
const lastUploadedBackup = this._lastUploadedBackup(this.backups);
|
||||
@@ -122,25 +161,33 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
)}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${lastUploadedBackup
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.backup.overview.summary.last_successful_backup_description",
|
||||
{
|
||||
relative_time: relativeTime(
|
||||
new Date(lastUploadedBackup.date),
|
||||
this.hass.locale,
|
||||
now,
|
||||
true
|
||||
),
|
||||
count: lastUploadedBackup.agent_ids?.length ?? 0,
|
||||
}
|
||||
)
|
||||
: nextBackupDescription}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
${lastUploadedBackup || nextBackupDescription
|
||||
? html`
|
||||
<ha-md-list-item>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiCalendar}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${lastUploadedBackup
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.backup.overview.summary.last_successful_backup_description",
|
||||
{
|
||||
relative_time: relativeTime(
|
||||
new Date(lastUploadedBackup.date),
|
||||
this.hass.locale,
|
||||
now,
|
||||
true
|
||||
),
|
||||
count: Object.keys(lastUploadedBackup.agents)
|
||||
.length,
|
||||
}
|
||||
)
|
||||
: nextBackupDescription}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
: nothing}
|
||||
</ha-md-list>
|
||||
</ha-backup-summary-card>
|
||||
`;
|
||||
@@ -164,10 +211,11 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
)}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
|
||||
<span slot="headline">${nextBackupDescription}</span>
|
||||
</ha-md-list-item>
|
||||
${this._renderNextBackupDescription(
|
||||
nextBackupDescription,
|
||||
lastCompletedDate,
|
||||
showAdditionalBackupDescription
|
||||
)}
|
||||
</ha-md-list>
|
||||
</ha-backup-summary-card>
|
||||
`;
|
||||
@@ -203,25 +251,29 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
)}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${lastUploadedBackup
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.backup.overview.summary.last_successful_backup_description",
|
||||
{
|
||||
relative_time: relativeTime(
|
||||
new Date(lastUploadedBackup.date),
|
||||
this.hass.locale,
|
||||
now,
|
||||
true
|
||||
),
|
||||
count: lastUploadedBackup.agent_ids?.length ?? 0,
|
||||
}
|
||||
)
|
||||
: nextBackupDescription}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
|
||||
${lastUploadedBackup || nextBackupDescription
|
||||
? html` <ha-md-list-item>
|
||||
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${lastUploadedBackup
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.backup.overview.summary.last_successful_backup_description",
|
||||
{
|
||||
relative_time: relativeTime(
|
||||
new Date(lastUploadedBackup.date),
|
||||
this.hass.locale,
|
||||
now,
|
||||
true
|
||||
),
|
||||
count: Object.keys(lastUploadedBackup.agents)
|
||||
.length,
|
||||
}
|
||||
)
|
||||
: nextBackupDescription}
|
||||
</span>
|
||||
</ha-md-list-item>`
|
||||
: nothing}
|
||||
</ha-md-list>
|
||||
</ha-backup-summary-card>
|
||||
`;
|
||||
@@ -236,7 +288,7 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
now,
|
||||
true
|
||||
),
|
||||
count: lastBackup.agent_ids?.length ?? 0,
|
||||
count: Object.keys(lastBackup.agents).length,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -248,53 +300,71 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
|
||||
const isOverdue =
|
||||
(numberOfDays >= 1 &&
|
||||
this.config.schedule.state === BackupScheduleState.DAILY) ||
|
||||
this.config.schedule.recurrence === BackupScheduleRecurrence.DAILY) ||
|
||||
numberOfDays >= 7;
|
||||
|
||||
if (isOverdue) {
|
||||
return html`
|
||||
<ha-backup-summary-card
|
||||
.heading=${this.hass.localize(
|
||||
"ui.panel.config.backup.overview.summary.backup_too_old_heading",
|
||||
{ count: numberOfDays }
|
||||
)}
|
||||
status="warning"
|
||||
>
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
|
||||
<span slot="headline">${lastSuccessfulBackupDescription}</span>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
|
||||
<span slot="headline">${nextBackupDescription}</span>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</ha-backup-summary-card>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-backup-summary-card
|
||||
.heading=${this.hass.localize(
|
||||
"ui.panel.config.backup.overview.summary.backup_success_heading"
|
||||
`ui.panel.config.backup.overview.summary.${isOverdue ? "backup_too_old_heading" : "backup_success_heading"}`,
|
||||
{ count: numberOfDays }
|
||||
)}
|
||||
status="success"
|
||||
.status=${isOverdue ? "warning" : "success"}
|
||||
>
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
|
||||
<span slot="headline">${lastSuccessfulBackupDescription}</span>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
|
||||
<span slot="headline">${nextBackupDescription}</span>
|
||||
</ha-md-list-item>
|
||||
${this._renderNextBackupDescription(
|
||||
nextBackupDescription,
|
||||
lastCompletedDate,
|
||||
showAdditionalBackupDescription
|
||||
)}
|
||||
</ha-md-list>
|
||||
</ha-backup-summary-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderNextBackupDescription(
|
||||
nextBackupDescription: string,
|
||||
lastCompletedDate: Date,
|
||||
showTip = false
|
||||
) {
|
||||
// handle edge case that there is an additional backup scheduled
|
||||
const openAdditionalBackupDescriptionDialog = showTip
|
||||
? () => {
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.backup.overview.summary.additional_backup_description",
|
||||
{
|
||||
date: formatDate(
|
||||
lastCompletedDate,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return nextBackupDescription
|
||||
? html`<ha-md-list-item>
|
||||
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
|
||||
<span slot="headline">${nextBackupDescription}</span>
|
||||
|
||||
${showTip
|
||||
? html` <ha-icon-button
|
||||
slot="end"
|
||||
@click=${openAdditionalBackupDescriptionDialog}
|
||||
.path=${mdiInformation}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
</ha-md-list-item>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
@@ -21,7 +21,7 @@ import type {
|
||||
BackupMutableConfig,
|
||||
} from "../../../../data/backup";
|
||||
import {
|
||||
BackupScheduleState,
|
||||
BackupScheduleRecurrence,
|
||||
CLOUD_AGENT,
|
||||
CORE_LOCAL_AGENT,
|
||||
downloadEmergencyKit,
|
||||
@@ -68,10 +68,15 @@ const RECOMMENDED_CONFIG: BackupConfig = {
|
||||
days: null,
|
||||
},
|
||||
schedule: {
|
||||
state: BackupScheduleState.DAILY,
|
||||
recurrence: BackupScheduleRecurrence.DAILY,
|
||||
time: null,
|
||||
days: [],
|
||||
},
|
||||
agents: {},
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
next_automatic_backup: null,
|
||||
next_automatic_backup_additional: false,
|
||||
};
|
||||
|
||||
@customElement("ha-dialog-backup-onboarding")
|
||||
@@ -145,7 +150,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
|
||||
include_database: this._config.create_backup.include_database,
|
||||
agent_ids: this._config.create_backup.agent_ids,
|
||||
},
|
||||
schedule: this._config.schedule.state,
|
||||
schedule: this._config.schedule,
|
||||
retention: this._config.retention,
|
||||
};
|
||||
|
||||
|
@@ -0,0 +1,225 @@
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-icon-next";
|
||||
import "../../../../components/ha-md-dialog";
|
||||
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
|
||||
import "../../../../components/ha-md-list";
|
||||
import "../../../../components/ha-md-list-item";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-password-field";
|
||||
import "../../../../components/ha-alert";
|
||||
import {
|
||||
canDecryptBackupOnDownload,
|
||||
getPreferredAgentForDownload,
|
||||
} from "../../../../data/backup";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { downloadBackupFile } from "../helper/download_backup";
|
||||
import type { DownloadDecryptedBackupDialogParams } from "./show-dialog-download-decrypted-backup";
|
||||
|
||||
@customElement("ha-dialog-download-decrypted-backup")
|
||||
class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _opened = false;
|
||||
|
||||
@state() private _params?: DownloadDecryptedBackupDialogParams;
|
||||
|
||||
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||
|
||||
@state() private _encryptionKey = "";
|
||||
|
||||
@state() private _error = "";
|
||||
|
||||
public showDialog(params: DownloadDecryptedBackupDialogParams): void {
|
||||
this._opened = true;
|
||||
this._params = params;
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
this._dialog?.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
if (this._opened) {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
this._opened = false;
|
||||
this._params = undefined;
|
||||
this._encryptionKey = "";
|
||||
this._error = "";
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._opened || !this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-md-dialog open @closed=${this._dialogClosed} disable-cancel-action>
|
||||
<ha-dialog-header slot="headline">
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
@click=${this.closeDialog}
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
<span slot="title">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.dialogs.download.title"
|
||||
)}
|
||||
</span>
|
||||
</ha-dialog-header>
|
||||
|
||||
<div slot="content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.dialogs.download.description"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.dialogs.download.download_backup_encrypted",
|
||||
{
|
||||
download_it_encrypted: html`<button
|
||||
class="link"
|
||||
@click=${this._downloadEncrypted}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.dialogs.download.download_it_encrypted"
|
||||
)}
|
||||
</button>`,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ha-password-field
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.backup.dialogs.download.encryption_key"
|
||||
)}
|
||||
@input=${this._keyChanged}
|
||||
></ha-password-field>
|
||||
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div slot="actions">
|
||||
<ha-button @click=${this._cancel}>
|
||||
${this.hass.localize("ui.dialogs.generic.cancel")}
|
||||
</ha-button>
|
||||
|
||||
<ha-button @click=${this._submit}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.dialogs.download.download"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-md-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _cancel() {
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private async _submit() {
|
||||
if (this._encryptionKey === "") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await canDecryptBackupOnDownload(
|
||||
this.hass,
|
||||
this._params!.backup.backup_id,
|
||||
this._agentId,
|
||||
this._encryptionKey
|
||||
);
|
||||
downloadBackupFile(
|
||||
this.hass,
|
||||
this._params!.backup.backup_id,
|
||||
this._agentId,
|
||||
this._encryptionKey
|
||||
);
|
||||
this.closeDialog();
|
||||
} catch (err: any) {
|
||||
if (err?.code === "password_incorrect") {
|
||||
this._error = this.hass.localize(
|
||||
"ui.panel.config.backup.dialogs.download.incorrect_encryption_key"
|
||||
);
|
||||
} else if (err?.code === "decrypt_not_supported") {
|
||||
this._error = this.hass.localize(
|
||||
"ui.panel.config.backup.dialogs.download.decryption_not_supported"
|
||||
);
|
||||
} else {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _keyChanged(ev) {
|
||||
this._encryptionKey = ev.currentTarget.value;
|
||||
this._error = "";
|
||||
}
|
||||
|
||||
private get _agentId() {
|
||||
if (this._params?.agentId) {
|
||||
return this._params.agentId;
|
||||
}
|
||||
return getPreferredAgentForDownload(
|
||||
Object.keys(this._params!.backup.agents)
|
||||
);
|
||||
}
|
||||
|
||||
private async _downloadEncrypted() {
|
||||
downloadBackupFile(
|
||||
this.hass,
|
||||
this._params!.backup.backup_id,
|
||||
this._agentId
|
||||
);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-md-dialog {
|
||||
--dialog-content-padding: 8px 24px;
|
||||
max-width: 500px;
|
||||
}
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
ha-md-dialog {
|
||||
max-width: none;
|
||||
}
|
||||
div[slot="content"] {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
button.link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-dialog-download-decrypted-backup": DialogDownloadDecryptedBackup;
|
||||
}
|
||||
}
|
@@ -18,6 +18,7 @@ import "../../../../components/ha-md-select";
|
||||
import "../../../../components/ha-md-select-option";
|
||||
import "../../../../components/ha-textfield";
|
||||
import type {
|
||||
BackupAgent,
|
||||
BackupConfig,
|
||||
GenerateBackupParams,
|
||||
} from "../../../../data/backup";
|
||||
@@ -64,7 +65,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
|
||||
|
||||
@state() private _step?: "data" | "sync";
|
||||
|
||||
@state() private _agentIds: string[] = [];
|
||||
@state() private _agents: BackupAgent[] = [];
|
||||
|
||||
@state() private _backupConfig?: BackupConfig;
|
||||
|
||||
@@ -89,7 +90,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
|
||||
}
|
||||
this._step = undefined;
|
||||
this._formData = undefined;
|
||||
this._agentIds = [];
|
||||
this._agents = [];
|
||||
this._backupConfig = undefined;
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
@@ -97,15 +98,14 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
|
||||
|
||||
private async _fetchAgents() {
|
||||
const { agents } = await fetchBackupAgentsInfo(this.hass);
|
||||
this._agentIds = agents
|
||||
.map((agent) => agent.agent_id)
|
||||
this._agents = agents
|
||||
.filter(
|
||||
(id) =>
|
||||
id !== CLOUD_AGENT ||
|
||||
(agent) =>
|
||||
agent.agent_id !== CLOUD_AGENT ||
|
||||
(this._params?.cloudStatus?.logged_in &&
|
||||
this._params?.cloudStatus?.active_subscription)
|
||||
)
|
||||
.sort(compareAgents);
|
||||
.sort((a, b) => compareAgents(a.agent_id, b.agent_id));
|
||||
}
|
||||
|
||||
private async _fetchBackupConfig() {
|
||||
@@ -134,6 +134,10 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
|
||||
this._step = STEPS[index + 1];
|
||||
}
|
||||
|
||||
private get _allAgentIds() {
|
||||
return this._agents.map((agent) => agent.agent_id);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
super.willUpdate(changedProperties);
|
||||
|
||||
@@ -144,7 +148,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
|
||||
// Remove disallowed agents from the list
|
||||
const agentsIds =
|
||||
this._formData.agents_mode === "all"
|
||||
? this._agentIds
|
||||
? this._allAgentIds
|
||||
: this._formData.agent_ids;
|
||||
|
||||
const filteredAgents = agentsIds.filter(
|
||||
@@ -309,7 +313,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.dialogs.generate.sync.locations_options.all",
|
||||
{ count: this._agentIds.length }
|
||||
{ count: this._allAgentIds.length }
|
||||
)}
|
||||
</div>
|
||||
</ha-md-select-option>
|
||||
@@ -350,7 +354,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
|
||||
.hass=${this.hass}
|
||||
.value=${this._formData.agent_ids}
|
||||
@value-changed=${this._agentsChanged}
|
||||
.agentIds=${this._agentIds}
|
||||
.agents=${this._agents}
|
||||
.disabledAgentIds=${disabledAgentIds}
|
||||
></ha-backup-agents-picker>
|
||||
</ha-expansion-panel>
|
||||
@@ -385,7 +389,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
|
||||
if (!this._formData) {
|
||||
return [];
|
||||
}
|
||||
const allAgents = this._agentIds;
|
||||
const allAgents = this._allAgentIds;
|
||||
return !this._formData.data.include_homeassistant
|
||||
? DISALLOWED_AGENTS_NO_HA.filter((agentId) => allAgents.includes(agentId))
|
||||
: [];
|
||||
@@ -403,7 +407,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
|
||||
const params: GenerateBackupParams = {
|
||||
name,
|
||||
password,
|
||||
agent_ids: agents_mode === "all" ? this._agentIds : agent_ids,
|
||||
agent_ids: agents_mode === "all" ? this._allAgentIds : agent_ids,
|
||||
// We always include homeassistant if we include database
|
||||
include_homeassistant:
|
||||
data.include_homeassistant || data.include_database,
|
||||
|
@@ -1,33 +1,35 @@
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-circular-progress";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
import "../../../../components/ha-password-field";
|
||||
|
||||
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-md-dialog";
|
||||
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { RestoreBackupParams } from "../../../../data/backup";
|
||||
import {
|
||||
fetchBackupConfig,
|
||||
getPreferredAgentForDownload,
|
||||
restoreBackup,
|
||||
} from "../../../../data/backup";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { RestoreBackupDialogParams } from "./show-dialog-restore-backup";
|
||||
import type {
|
||||
RestoreBackupStage,
|
||||
RestoreBackupState,
|
||||
} from "../../../../data/backup_manager";
|
||||
import { subscribeBackupEvents } from "../../../../data/backup_manager";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { RestoreBackupDialogParams } from "./show-dialog-restore-backup";
|
||||
|
||||
interface FormData {
|
||||
encryption_key_type: "config" | "custom";
|
||||
@@ -76,7 +78,12 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
|
||||
this._error = undefined;
|
||||
this._state = undefined;
|
||||
this._stage = undefined;
|
||||
if (this._params.backup.protected) {
|
||||
|
||||
const agentIds = Object.keys(this._params.backup.agents);
|
||||
const preferedAgent = getPreferredAgentForDownload(agentIds);
|
||||
const isProtected = this._params.backup.agents[preferedAgent]?.protected;
|
||||
|
||||
if (isProtected) {
|
||||
this._backupEncryptionKey = await this._fetchEncryptionKey();
|
||||
if (!this._backupEncryptionKey) {
|
||||
this._step = STEPS[1];
|
||||
@@ -222,7 +229,7 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
|
||||
<ha-button @click=${this.closeDialog}>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button @click=${this._restoreBackup} class="destructive">
|
||||
<ha-button @click=${this._restoreBackup} destructive>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.dialogs.restore.actions.restore"
|
||||
)}
|
||||
@@ -320,22 +327,26 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferedAgent = getPreferredAgentForDownload(
|
||||
this._params.backup.agent_ids!
|
||||
);
|
||||
const agentIds = Object.keys(this._params.backup.agents);
|
||||
const preferedAgent = getPreferredAgentForDownload(agentIds);
|
||||
|
||||
const { addons, database_included, homeassistant_included, folders } =
|
||||
this._params.selectedData;
|
||||
|
||||
await restoreBackup(this.hass, {
|
||||
const restoreParams: RestoreBackupParams = {
|
||||
backup_id: this._params.backup.backup_id,
|
||||
agent_id: preferedAgent,
|
||||
password,
|
||||
restore_addons: addons.map((addon) => addon.slug),
|
||||
restore_database: database_included,
|
||||
restore_folders: folders,
|
||||
restore_homeassistant: homeassistant_included,
|
||||
});
|
||||
};
|
||||
|
||||
if (isComponentLoaded(this.hass, "hassio")) {
|
||||
restoreParams.restore_addons = addons.map((addon) => addon.slug);
|
||||
restoreParams.restore_folders = folders;
|
||||
}
|
||||
|
||||
await restoreBackup(this.hass, restoreParams);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
@@ -350,9 +361,6 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
|
||||
.content p {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.destructive {
|
||||
--mdc-theme-primary: var(--error-color);
|
||||
}
|
||||
.centered {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@@ -0,0 +1,21 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { BackupContent } from "../../../../data/backup";
|
||||
|
||||
export interface DownloadDecryptedBackupDialogParams {
|
||||
backup: BackupContent;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export const loadDownloadDecryptedBackupDialog = () =>
|
||||
import("./dialog-download-decrypted-backup");
|
||||
|
||||
export const showDownloadDecryptedBackupDialog = (
|
||||
element: HTMLElement,
|
||||
params: DownloadDecryptedBackupDialogParams
|
||||
) => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "ha-dialog-download-decrypted-backup",
|
||||
dialogImport: loadDownloadDecryptedBackupDialog,
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
@@ -11,6 +11,7 @@ import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { relativeTime } from "../../../common/datetime/relative_time";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
@@ -33,15 +34,20 @@ import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-icon-overflow-menu";
|
||||
import "../../../components/ha-list-item";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { getSignedPath } from "../../../data/auth";
|
||||
import type { BackupConfig, BackupContent } from "../../../data/backup";
|
||||
import type {
|
||||
BackupAgent,
|
||||
BackupConfig,
|
||||
BackupContent,
|
||||
} from "../../../data/backup";
|
||||
import {
|
||||
compareAgents,
|
||||
computeBackupAgentName,
|
||||
computeBackupSize,
|
||||
computeBackupType,
|
||||
deleteBackup,
|
||||
generateBackup,
|
||||
generateBackupWithAutomaticSettings,
|
||||
getBackupDownloadUrl,
|
||||
getPreferredAgentForDownload,
|
||||
getBackupTypes,
|
||||
isLocalAgent,
|
||||
isNetworkMountAgent,
|
||||
} from "../../../data/backup";
|
||||
@@ -60,19 +66,17 @@ import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import { bytesToString } from "../../../util/bytes-to-string";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup";
|
||||
import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup";
|
||||
import { showUploadBackupDialog } from "./dialogs/show-dialog-upload-backup";
|
||||
import { downloadBackup } from "./helper/download_backup";
|
||||
|
||||
interface BackupRow extends DataTableRowData, BackupContent {
|
||||
formatted_type: string;
|
||||
size: number;
|
||||
agent_ids: string[];
|
||||
}
|
||||
|
||||
type BackupType = "automatic" | "manual";
|
||||
|
||||
const TYPE_ORDER: BackupType[] = ["automatic", "manual"];
|
||||
|
||||
@customElement("ha-config-backup-backups")
|
||||
class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -89,6 +93,8 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ attribute: false }) public config?: BackupConfig;
|
||||
|
||||
@property({ attribute: false }) public agents: BackupAgent[] = [];
|
||||
|
||||
@state() private _selected: string[] = [];
|
||||
|
||||
@storage({
|
||||
@@ -134,7 +140,10 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
};
|
||||
|
||||
private _columns = memoizeOne(
|
||||
(localize: LocalizeFunc): DataTableColumnContainer<BackupRow> => ({
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
maxDisplayedAgents: number
|
||||
): DataTableColumnContainer<BackupRow> => ({
|
||||
name: {
|
||||
title: localize("ui.panel.config.backup.name"),
|
||||
main: true,
|
||||
@@ -165,54 +174,75 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
locations: {
|
||||
title: localize("ui.panel.config.backup.locations"),
|
||||
showNarrow: true,
|
||||
minWidth: "60px",
|
||||
template: (backup) => html`
|
||||
<div style="display: flex; gap: 4px;">
|
||||
${(backup.agent_ids || []).map((agentId) => {
|
||||
const name = computeBackupAgentName(
|
||||
this.hass.localize,
|
||||
agentId,
|
||||
backup.agent_ids
|
||||
);
|
||||
if (isLocalAgent(agentId)) {
|
||||
// 24 icon size, 4 gap, 16 left and right padding
|
||||
minWidth: `${maxDisplayedAgents * 24 + (maxDisplayedAgents - 1) * 4 + 32}px`,
|
||||
template: (backup) => {
|
||||
const agentIds = backup.agent_ids;
|
||||
const displayedAgentIds =
|
||||
agentIds.length > maxDisplayedAgents
|
||||
? [...agentIds].splice(0, maxDisplayedAgents - 1)
|
||||
: agentIds;
|
||||
const agentsMore = Math.max(
|
||||
agentIds.length - displayedAgentIds.length,
|
||||
0
|
||||
);
|
||||
return html`
|
||||
<div style="display: flex; gap: 4px;">
|
||||
${displayedAgentIds.map((agentId) => {
|
||||
const name = computeBackupAgentName(
|
||||
this.hass.localize,
|
||||
agentId,
|
||||
this.agents
|
||||
);
|
||||
if (isLocalAgent(agentId)) {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
.path=${mdiHarddisk}
|
||||
title=${name}
|
||||
style="flex-shrink: 0;"
|
||||
></ha-svg-icon>
|
||||
`;
|
||||
}
|
||||
if (isNetworkMountAgent(agentId)) {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
.path=${mdiNas}
|
||||
title=${name}
|
||||
style="flex-shrink: 0;"
|
||||
></ha-svg-icon>
|
||||
`;
|
||||
}
|
||||
const domain = computeDomain(agentId);
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
.path=${mdiHarddisk}
|
||||
<img
|
||||
title=${name}
|
||||
.src=${brandsUrl({
|
||||
domain,
|
||||
type: "icon",
|
||||
useFallback: true,
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
})}
|
||||
height="24"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=${name}
|
||||
slot="graphic"
|
||||
style="flex-shrink: 0;"
|
||||
></ha-svg-icon>
|
||||
/>
|
||||
`;
|
||||
}
|
||||
if (isNetworkMountAgent(agentId)) {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
.path=${mdiNas}
|
||||
title=${name}
|
||||
style="flex-shrink: 0;"
|
||||
></ha-svg-icon>
|
||||
`;
|
||||
}
|
||||
const domain = computeDomain(agentId);
|
||||
return html`
|
||||
<img
|
||||
title=${name}
|
||||
.src=${brandsUrl({
|
||||
domain,
|
||||
type: "icon",
|
||||
useFallback: true,
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
})}
|
||||
height="24"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=${name}
|
||||
slot="graphic"
|
||||
style="flex-shrink: 0;"
|
||||
/>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
})}
|
||||
${agentsMore
|
||||
? html`
|
||||
<span
|
||||
style="display: flex; align-items: center; font-size: 14px;"
|
||||
>
|
||||
+${agentsMore}
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
title: "",
|
||||
@@ -246,9 +276,13 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
|
||||
private _groupOrder = memoizeOne(
|
||||
(activeGrouping: string | undefined, localize: LocalizeFunc) =>
|
||||
(
|
||||
activeGrouping: string | undefined,
|
||||
localize: LocalizeFunc,
|
||||
isHassio: boolean
|
||||
) =>
|
||||
activeGrouping === "formatted_type"
|
||||
? TYPE_ORDER.map((type) =>
|
||||
? getBackupTypes(isHassio).map((type) =>
|
||||
localize(`ui.panel.config.backup.type.${type}`)
|
||||
)
|
||||
: undefined
|
||||
@@ -272,31 +306,48 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
(
|
||||
backups: BackupContent[],
|
||||
filters: DataTableFiltersValues,
|
||||
localize: LocalizeFunc
|
||||
localize: LocalizeFunc,
|
||||
isHassio: boolean
|
||||
): BackupRow[] => {
|
||||
const typeFilter = filters["ha-filter-states"] as string[] | undefined;
|
||||
let filteredBackups = backups;
|
||||
if (typeFilter?.length) {
|
||||
filteredBackups = filteredBackups.filter(
|
||||
(backup) =>
|
||||
(backup.with_automatic_settings &&
|
||||
typeFilter.includes("automatic")) ||
|
||||
(!backup.with_automatic_settings && typeFilter.includes("manual"))
|
||||
);
|
||||
filteredBackups = filteredBackups.filter((backup) => {
|
||||
const type = computeBackupType(backup, isHassio);
|
||||
return typeFilter.includes(type);
|
||||
});
|
||||
}
|
||||
return filteredBackups.map((backup) => {
|
||||
const type = backup.with_automatic_settings ? "automatic" : "manual";
|
||||
const type = computeBackupType(backup, isHassio);
|
||||
const agentIds = Object.keys(backup.agents);
|
||||
return {
|
||||
...backup,
|
||||
size: computeBackupSize(backup),
|
||||
agent_ids: agentIds.sort(compareAgents),
|
||||
formatted_type: localize(`ui.panel.config.backup.type.${type}`),
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private _maxAgents = memoizeOne((data: BackupRow[]): number =>
|
||||
Math.max(...data.map((row) => row.agent_ids.length))
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const backupInProgress =
|
||||
"state" in this.manager && this.manager.state === "in_progress";
|
||||
const isHassio = isComponentLoaded(this.hass, "hassio");
|
||||
const data = this._data(
|
||||
this.backups,
|
||||
this._filters,
|
||||
this.hass.localize,
|
||||
isHassio
|
||||
);
|
||||
const maxDisplayedAgents = Math.min(
|
||||
this._maxAgents(data),
|
||||
this.narrow ? 3 : 5
|
||||
);
|
||||
|
||||
return html`
|
||||
<hass-tabs-subpage-data-table
|
||||
@@ -327,15 +378,16 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.groupOrder=${this._groupOrder(
|
||||
this._activeGrouping,
|
||||
this.hass.localize
|
||||
this.hass.localize,
|
||||
isHassio
|
||||
)}
|
||||
@grouping-changed=${this._handleGroupingChanged}
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
@selection-changed=${this._handleSelectionChanged}
|
||||
.route=${this.route}
|
||||
@row-click=${this._showBackupDetails}
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.data=${this._data(this.backups, this._filters, this.hass.localize)}
|
||||
.columns=${this._columns(this.hass.localize, maxDisplayedAgents)}
|
||||
.data=${data}
|
||||
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
|
||||
.searchLabel=${this.hass.localize(
|
||||
"ui.panel.config.backup.picker.search"
|
||||
@@ -391,7 +443,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize("ui.panel.config.backup.backup_type")}
|
||||
.value=${this._filters["ha-filter-states"]}
|
||||
.states=${this._states(this.hass.localize)}
|
||||
.states=${this._states(this.hass.localize, isHassio)}
|
||||
@data-table-filter-changed=${this._filterChanged}
|
||||
slot="filter-pane"
|
||||
expanded
|
||||
@@ -416,8 +468,8 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
`;
|
||||
}
|
||||
|
||||
private _states = memoizeOne((localize: LocalizeFunc) =>
|
||||
TYPE_ORDER.map((type) => ({
|
||||
private _states = memoizeOne((localize: LocalizeFunc, isHassio: boolean) =>
|
||||
getBackupTypes(isHassio).map((type) => ({
|
||||
value: type,
|
||||
label: localize(`ui.panel.config.backup.type.${type}`),
|
||||
}))
|
||||
@@ -487,12 +539,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private async _downloadBackup(backup: BackupContent): Promise<void> {
|
||||
const preferedAgent = getPreferredAgentForDownload(backup!.agent_ids!);
|
||||
const signedUrl = await getSignedPath(
|
||||
this.hass,
|
||||
getBackupDownloadUrl(backup.backup_id, preferedAgent)
|
||||
);
|
||||
fileDownload(signedUrl.path);
|
||||
downloadBackup(this.hass, this, backup, this.config);
|
||||
}
|
||||
|
||||
private async _deleteBackup(backup: BackupContent): Promise<void> {
|
||||
|
@@ -20,15 +20,20 @@ import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-list-item";
|
||||
import "../../../components/ha-md-list";
|
||||
import "../../../components/ha-md-list-item";
|
||||
import { getSignedPath } from "../../../data/auth";
|
||||
import type { BackupContentExtended, BackupData } from "../../../data/backup";
|
||||
import type {
|
||||
BackupAgent,
|
||||
BackupConfig,
|
||||
BackupContentAgent,
|
||||
BackupContentExtended,
|
||||
BackupData,
|
||||
} from "../../../data/backup";
|
||||
import {
|
||||
compareAgents,
|
||||
computeBackupAgentName,
|
||||
computeBackupSize,
|
||||
computeBackupType,
|
||||
deleteBackup,
|
||||
fetchBackupDetails,
|
||||
getBackupDownloadUrl,
|
||||
getPreferredAgentForDownload,
|
||||
isLocalAgent,
|
||||
isNetworkMountAgent,
|
||||
} from "../../../data/backup";
|
||||
@@ -37,27 +42,38 @@ import "../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import { bytesToString } from "../../../util/bytes-to-string";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import { showConfirmationDialog } from "../../lovelace/custom-card-helpers";
|
||||
import "./components/ha-backup-data-picker";
|
||||
import { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { downloadBackup } from "./helper/download_backup";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
|
||||
interface Agent {
|
||||
interface Agent extends BackupContentAgent {
|
||||
id: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
const computeAgents = (agent_ids: string[], failed_agent_ids: string[]) =>
|
||||
[
|
||||
...agent_ids.filter((id) => !failed_agent_ids.includes(id)),
|
||||
...failed_agent_ids,
|
||||
const computeAgents = (backup: BackupContentExtended) => {
|
||||
const agentIds = Object.keys(backup.agents);
|
||||
const failedAgentIds = backup.failed_agent_ids ?? [];
|
||||
return [
|
||||
...agentIds.filter((id) => !failedAgentIds.includes(id)),
|
||||
...failedAgentIds,
|
||||
]
|
||||
.map<Agent>((id) => ({
|
||||
id,
|
||||
success: !failed_agent_ids.includes(id),
|
||||
}))
|
||||
.map<Agent>((id) => {
|
||||
const agent: BackupContentAgent = backup.agents[id] ?? {
|
||||
protected: false,
|
||||
size: 0,
|
||||
};
|
||||
return {
|
||||
...agent,
|
||||
id: id,
|
||||
success: !failedAgentIds.includes(id),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => compareAgents(a.id, b.id));
|
||||
};
|
||||
|
||||
@customElement("ha-config-backup-details")
|
||||
class HaConfigBackupDetails extends LitElement {
|
||||
@@ -67,6 +83,10 @@ class HaConfigBackupDetails extends LitElement {
|
||||
|
||||
@property({ attribute: "backup-id" }) public backupId!: string;
|
||||
|
||||
@property({ attribute: false }) public config?: BackupConfig;
|
||||
|
||||
@property({ attribute: false }) public agents: BackupAgent[] = [];
|
||||
|
||||
@state() private _backup?: BackupContentExtended | null;
|
||||
|
||||
@state() private _agents: Agent[] = [];
|
||||
@@ -92,6 +112,8 @@ class HaConfigBackupDetails extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const isHassio = isComponentLoaded(this.hass, "hassio");
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
back-path="/config/backup/backups"
|
||||
@@ -143,6 +165,18 @@ class HaConfigBackupDetails extends LitElement {
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<ha-md-list class="summary">
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.backup_type"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.type.${computeBackupType(this._backup, isHassio)}`
|
||||
)}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
@@ -150,7 +184,7 @@ class HaConfigBackupDetails extends LitElement {
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${bytesToString(this._backup.size)}
|
||||
${bytesToString(computeBackupSize(this._backup))}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
@@ -167,22 +201,6 @@ class HaConfigBackupDetails extends LitElement {
|
||||
)}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.details.summary.protection"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this._backup.protected
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.backup.details.summary.protected_encrypted_aes_128"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.backup.details.summary.protected_not_encrypted"
|
||||
)}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
@@ -224,87 +242,112 @@ class HaConfigBackupDetails extends LitElement {
|
||||
<ha-md-list>
|
||||
${this._agents.map((agent) => {
|
||||
const agentId = agent.id;
|
||||
const success = agent.success;
|
||||
|
||||
const domain = computeDomain(agentId);
|
||||
const name = computeBackupAgentName(
|
||||
this.hass.localize,
|
||||
agentId,
|
||||
this._backup!.agent_ids
|
||||
this.agents
|
||||
);
|
||||
const success = agent.success;
|
||||
const failed = !agent.success;
|
||||
const unencrypted = !agent.protected;
|
||||
|
||||
return html`
|
||||
<ha-md-list-item>
|
||||
${isLocalAgent(agentId)
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
.path=${mdiHarddisk}
|
||||
slot="start"
|
||||
>
|
||||
</ha-svg-icon>
|
||||
`
|
||||
: isNetworkMountAgent(agentId)
|
||||
${
|
||||
isLocalAgent(agentId)
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
.path=${mdiNas}
|
||||
.path=${mdiHarddisk}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
>
|
||||
</ha-svg-icon>
|
||||
`
|
||||
: html`
|
||||
<img
|
||||
.src=${brandsUrl({
|
||||
domain,
|
||||
type: "icon",
|
||||
useFallback: true,
|
||||
darkOptimized:
|
||||
this.hass.themes?.darkMode,
|
||||
})}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=""
|
||||
slot="start"
|
||||
/>
|
||||
`}
|
||||
: isNetworkMountAgent(agentId)
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
.path=${mdiNas}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: html`
|
||||
<img
|
||||
.src=${brandsUrl({
|
||||
domain,
|
||||
type: "icon",
|
||||
useFallback: true,
|
||||
darkOptimized:
|
||||
this.hass.themes?.darkMode,
|
||||
})}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=${`${domain} logo`}
|
||||
slot="start"
|
||||
/>
|
||||
`
|
||||
}
|
||||
<div slot="headline">${name}</div>
|
||||
<div slot="supporting-text">
|
||||
<span
|
||||
class="dot ${success ? "success" : "error"}"
|
||||
>
|
||||
</span>
|
||||
<span>
|
||||
${success
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.backup.details.locations.backup_stored"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.backup.details.locations.backup_failed"
|
||||
)}
|
||||
</span>
|
||||
<div slot="supporting-text">
|
||||
${
|
||||
failed
|
||||
? html`
|
||||
<span class="dot error"></span>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.details.locations.backup_failed"
|
||||
)}
|
||||
</span>
|
||||
`
|
||||
: unencrypted
|
||||
? html`
|
||||
<span class="dot warning"></span>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.details.locations.unencrypted"
|
||||
)}</span
|
||||
>
|
||||
`
|
||||
: html`
|
||||
<span class="dot success"></span>
|
||||
<span
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.backup.details.locations.encrypted"
|
||||
)}</span
|
||||
>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
${success
|
||||
? html`<ha-button-menu
|
||||
slot="end"
|
||||
@action=${this._handleAgentAction}
|
||||
.agent=${agentId}
|
||||
fixed
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize(
|
||||
"ui.common.menu"
|
||||
)}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-list-item graphic="icon">
|
||||
<ha-svg-icon
|
||||
slot="graphic"
|
||||
.path=${mdiDownload}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.details.locations.download"
|
||||
)}
|
||||
</ha-list-item>
|
||||
</ha-button-menu>`
|
||||
: nothing}
|
||||
${
|
||||
success
|
||||
? html`
|
||||
<ha-button-menu
|
||||
slot="end"
|
||||
@action=${this._handleAgentAction}
|
||||
.agent=${agentId}
|
||||
fixed
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize(
|
||||
"ui.common.menu"
|
||||
)}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-list-item graphic="icon">
|
||||
<ha-svg-icon
|
||||
slot="graphic"
|
||||
.path=${mdiDownload}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.details.locations.download"
|
||||
)}
|
||||
</ha-list-item>
|
||||
</ha-button-menu>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
})}
|
||||
@@ -348,10 +391,7 @@ class HaConfigBackupDetails extends LitElement {
|
||||
try {
|
||||
const response = await fetchBackupDetails(this.hass, this.backupId);
|
||||
this._backup = response.backup;
|
||||
this._agents = computeAgents(
|
||||
response.backup.agent_ids || [],
|
||||
response.backup.failed_agent_ids || []
|
||||
);
|
||||
this._agents = computeAgents(response.backup);
|
||||
} catch (err: any) {
|
||||
this._error =
|
||||
err?.message ||
|
||||
@@ -377,13 +417,7 @@ class HaConfigBackupDetails extends LitElement {
|
||||
}
|
||||
|
||||
private async _downloadBackup(agentId?: string): Promise<void> {
|
||||
const preferedAgent =
|
||||
agentId ?? getPreferredAgentForDownload(this._backup!.agent_ids!);
|
||||
const signedUrl = await getSignedPath(
|
||||
this.hass,
|
||||
getBackupDownloadUrl(this._backup!.backup_id, preferedAgent)
|
||||
);
|
||||
fileDownload(signedUrl.path);
|
||||
await downloadBackup(this.hass, this, this._backup!, this.config, agentId);
|
||||
}
|
||||
|
||||
private async _deleteBackup(): Promise<void> {
|
||||
@@ -473,6 +507,9 @@ class HaConfigBackupDetails extends LitElement {
|
||||
.dot.success {
|
||||
background-color: var(--success-color);
|
||||
}
|
||||
.dot.warning {
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
.dot.error {
|
||||
background-color: var(--error-color);
|
||||
}
|
||||
|
368
src/panels/config/backup/ha-config-backup-location.ts
Normal file
368
src/panels/config/backup/ha-config-backup-location.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-switch";
|
||||
import "../../../components/ha-button-menu";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-circular-progress";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-list-item";
|
||||
import "../../../components/ha-md-list";
|
||||
import "../../../components/ha-md-list-item";
|
||||
import type {
|
||||
BackupAgent,
|
||||
BackupAgentConfig,
|
||||
BackupConfig,
|
||||
} from "../../../data/backup";
|
||||
import {
|
||||
CLOUD_AGENT,
|
||||
computeBackupAgentName,
|
||||
fetchBackupAgentsInfo,
|
||||
updateBackupConfig,
|
||||
} from "../../../data/backup";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./components/ha-backup-data-picker";
|
||||
import { showConfirmationDialog } from "../../lovelace/custom-card-helpers";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
|
||||
@customElement("ha-config-backup-location")
|
||||
class HaConfigBackupDetails extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: "agent-id" }) public agentId!: string;
|
||||
|
||||
@property({ attribute: false }) public config?: BackupConfig;
|
||||
|
||||
@property({ attribute: false }) public agents: BackupAgent[] = [];
|
||||
|
||||
@state() private _agent?: BackupAgent | null;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has("agentId")) {
|
||||
if (this.agentId) {
|
||||
this._fetchAgent();
|
||||
} else {
|
||||
this._error = "Agent id not defined";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const encrypted = this._isEncryptionTurnedOn();
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
back-path="/config/backup/settings"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${(this._agent &&
|
||||
computeBackupAgentName(
|
||||
this.hass.localize,
|
||||
this.agentId,
|
||||
this.agents
|
||||
)) ||
|
||||
this.hass.localize("ui.panel.config.backup.location.header")}
|
||||
>
|
||||
<div class="content">
|
||||
${this._error &&
|
||||
html`<ha-alert alert-type="error">${this._error}</ha-alert>`}
|
||||
${this._agent === null
|
||||
? html`
|
||||
<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.backup.location.not_found"
|
||||
)}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.not_found_description",
|
||||
{ agentId: this.agentId }
|
||||
)}
|
||||
</ha-alert>
|
||||
`
|
||||
: !this.agentId
|
||||
? html`<ha-circular-progress active></ha-circular-progress>`
|
||||
: html`
|
||||
${CLOUD_AGENT === this.agentId
|
||||
? html`
|
||||
<ha-card>
|
||||
<div class="card-header">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.configuration.title"
|
||||
)}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.configuration.cloud_description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
: nothing}
|
||||
<ha-card>
|
||||
<div class="card-header">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.title"
|
||||
)}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.description"
|
||||
)}
|
||||
</p>
|
||||
<ha-md-list>
|
||||
${CLOUD_AGENT === this.agentId
|
||||
? html`
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.location_encrypted"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.location_encrypted_cloud_description"
|
||||
)}
|
||||
</span>
|
||||
<a
|
||||
href="https://www.nabucasa.com/config/backups/"
|
||||
target="_blank"
|
||||
slot="end"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<ha-button>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.location_encrypted_cloud_learn_more"
|
||||
)}
|
||||
</ha-button>
|
||||
</a>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
: encrypted
|
||||
? html`
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.location_encrypted"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.location.encryption.location_encrypted_description`
|
||||
)}
|
||||
</span>
|
||||
|
||||
<ha-button
|
||||
slot="end"
|
||||
@click=${this._turnOffEncryption}
|
||||
destructive
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.encryption_turn_off"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
: html`
|
||||
<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.warning_encryption_turn_off"
|
||||
)}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.warning_encryption_turn_off_description"
|
||||
)}
|
||||
</ha-alert>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.location_unencrypted"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.location.encryption.location_unencrypted_description`
|
||||
)}
|
||||
</span>
|
||||
|
||||
<ha-button
|
||||
slot="end"
|
||||
@click=${this._turnOnEncryption}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.encryption_turn_on"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
`}
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
`}
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _isEncryptionTurnedOn() {
|
||||
const agentConfig = this.config?.agents[this.agentId] as
|
||||
| BackupAgentConfig
|
||||
| undefined;
|
||||
|
||||
if (!agentConfig) {
|
||||
return true;
|
||||
}
|
||||
return agentConfig.protected;
|
||||
}
|
||||
|
||||
private async _fetchAgent() {
|
||||
try {
|
||||
const { agents } = await fetchBackupAgentsInfo(this.hass);
|
||||
const agent = agents.find((a) => a.agent_id === this.agentId);
|
||||
if (!agent) {
|
||||
throw new Error("Agent not found");
|
||||
}
|
||||
this._agent = agent;
|
||||
} catch (err: any) {
|
||||
this._error =
|
||||
err?.message ||
|
||||
this.hass.localize("ui.panel.config.backup.details.error");
|
||||
}
|
||||
}
|
||||
|
||||
private async _updateAgentEncryption(value: boolean) {
|
||||
const agentsConfig = {
|
||||
...this.config?.agents,
|
||||
[this.agentId]: {
|
||||
...this.config?.agents[this.agentId],
|
||||
protected: value,
|
||||
},
|
||||
};
|
||||
await updateBackupConfig(this.hass, {
|
||||
agents: agentsConfig,
|
||||
});
|
||||
fireEvent(this, "ha-refresh-backup-config");
|
||||
}
|
||||
|
||||
private _turnOnEncryption() {
|
||||
this._updateAgentEncryption(true);
|
||||
}
|
||||
|
||||
private async _turnOffEncryption() {
|
||||
const response = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.encryption_turn_off_confirm_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.encryption_turn_off_confirm_text"
|
||||
),
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.encryption_turn_off_confirm_action"
|
||||
),
|
||||
dismissText: this.hass.localize("ui.common.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
if (response) {
|
||||
this._updateAgentEncryption(false);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.content {
|
||||
padding: 28px 20px 0;
|
||||
max-width: 690px;
|
||||
margin: 0 auto;
|
||||
gap: 24px;
|
||||
display: grid;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card-content {
|
||||
padding: 0 20px;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
ha-md-list-item {
|
||||
--md-list-item-leading-space: 0;
|
||||
--md-list-item-trailing-space: 0;
|
||||
--md-list-item-two-line-container-height: 64px;
|
||||
}
|
||||
ha-md-list-item img {
|
||||
width: 48px;
|
||||
}
|
||||
ha-md-list-item ha-svg-icon[slot="start"] {
|
||||
--mdc-icon-size: 48px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
ha-md-list.summary ha-md-list-item {
|
||||
--md-list-item-supporting-text-size: 1rem;
|
||||
--md-list-item-label-text-size: 0.875rem;
|
||||
|
||||
--md-list-item-label-text-color: var(--secondary-text-color);
|
||||
--md-list-item-supporting-text-color: var(--primary-text-color);
|
||||
}
|
||||
.warning {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.warning ha-svg-icon {
|
||||
color: var(--error-color);
|
||||
}
|
||||
ha-button.danger {
|
||||
--mdc-theme-primary: var(--error-color);
|
||||
}
|
||||
ha-backup-data-picker {
|
||||
display: block;
|
||||
}
|
||||
ha-md-list-item [slot="supporting-text"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
line-height: normal;
|
||||
}
|
||||
.dot {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--disabled-color);
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.dot.success {
|
||||
background-color: var(--success-color);
|
||||
}
|
||||
.dot.error {
|
||||
background-color: var(--error-color);
|
||||
}
|
||||
.card-header {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-backup-location": HaConfigBackupDetails;
|
||||
}
|
||||
}
|
@@ -13,11 +13,14 @@ import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-icon-overflow-menu";
|
||||
import "../../../components/ha-list-item";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type {
|
||||
BackupAgent,
|
||||
BackupConfig,
|
||||
BackupContent,
|
||||
} from "../../../data/backup";
|
||||
import {
|
||||
generateBackup,
|
||||
generateBackupWithAutomaticSettings,
|
||||
type BackupConfig,
|
||||
type BackupContent,
|
||||
} from "../../../data/backup";
|
||||
import type { ManagerStateEvent } from "../../../data/backup_manager";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
@@ -53,6 +56,8 @@ class HaConfigBackupOverview extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public config?: BackupConfig;
|
||||
|
||||
@property({ attribute: false }) public agents: BackupAgent[] = [];
|
||||
|
||||
private async _uploadBackup(ev) {
|
||||
if (!shouldHandleRequestSelectedEvent(ev)) {
|
||||
return;
|
||||
@@ -184,6 +189,7 @@ class HaConfigBackupOverview extends LitElement {
|
||||
<ha-backup-overview-settings
|
||||
.hass=${this.hass}
|
||||
.config=${this.config!}
|
||||
.agents=${this.agents}
|
||||
></ha-backup-overview-settings>
|
||||
`
|
||||
: nothing}
|
||||
@@ -215,8 +221,7 @@ class HaConfigBackupOverview extends LitElement {
|
||||
gap: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 72px;
|
||||
margin-bottom: calc(env(safe-area-inset-bottom) + 72px);
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
|
@@ -16,7 +16,7 @@ import "../../../components/ha-list-item";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-password-field";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { BackupConfig } from "../../../data/backup";
|
||||
import type { BackupAgent, BackupConfig } from "../../../data/backup";
|
||||
import { updateBackupConfig } from "../../../data/backup";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import "../../../layouts/hass-subpage";
|
||||
@@ -39,6 +39,8 @@ class HaConfigBackupSettings extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public config?: BackupConfig;
|
||||
|
||||
@property({ attribute: false }) public agents: BackupAgent[] = [];
|
||||
|
||||
@state() private _config?: BackupConfig;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
@@ -48,9 +50,11 @@ class HaConfigBackupSettings extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(_changedProperties: PropertyValues): void {
|
||||
super.firstUpdated(_changedProperties);
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._scrollToSection();
|
||||
// Update config the page is displayed (e.g. when coming back from a location detail page)
|
||||
this._config = this.config;
|
||||
}
|
||||
|
||||
private async _scrollToSection() {
|
||||
@@ -177,8 +181,11 @@ class HaConfigBackupSettings extends LitElement {
|
||||
<ha-backup-config-agents
|
||||
.hass=${this.hass}
|
||||
.value=${this._config.create_backup.agent_ids}
|
||||
.agentsConfig=${this._config.agents}
|
||||
.cloudStatus=${this.cloudStatus}
|
||||
.agents=${this.agents}
|
||||
@value-changed=${this._agentsConfigChanged}
|
||||
show-settings
|
||||
></ha-backup-config-agents>
|
||||
${!this._config.create_backup.agent_ids.length
|
||||
? html`
|
||||
@@ -308,7 +315,7 @@ class HaConfigBackupSettings extends LitElement {
|
||||
password: this._config!.create_backup.password,
|
||||
},
|
||||
retention: this._config!.retention,
|
||||
schedule: this._config!.schedule.state,
|
||||
schedule: this._config!.schedule,
|
||||
});
|
||||
fireEvent(this, "ha-refresh-backup-config");
|
||||
}
|
||||
|
@@ -1,9 +1,14 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { BackupConfig, BackupContent } from "../../../data/backup";
|
||||
import type {
|
||||
BackupAgent,
|
||||
BackupConfig,
|
||||
BackupContent,
|
||||
} from "../../../data/backup";
|
||||
import {
|
||||
compareAgents,
|
||||
fetchBackupAgentsInfo,
|
||||
fetchBackupConfig,
|
||||
fetchBackupInfo,
|
||||
} from "../../../data/backup";
|
||||
@@ -41,6 +46,8 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
|
||||
|
||||
@state() private _backups: BackupContent[] = [];
|
||||
|
||||
@state() private _agents: BackupAgent[] = [];
|
||||
|
||||
@state() private _fetching = false;
|
||||
|
||||
@state() private _config?: BackupConfig;
|
||||
@@ -54,15 +61,20 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
|
||||
this.addEventListener("ha-refresh-backup-config", () => {
|
||||
this._fetchBackupConfig();
|
||||
});
|
||||
this.addEventListener("ha-refresh-backup-agents", () => {
|
||||
this._fetchBackupAgents();
|
||||
});
|
||||
}
|
||||
|
||||
private _fetchAll() {
|
||||
this._fetching = true;
|
||||
Promise.all([this._fetchBackupInfo(), this._fetchBackupConfig()]).finally(
|
||||
() => {
|
||||
this._fetching = false;
|
||||
}
|
||||
);
|
||||
Promise.all([
|
||||
this._fetchBackupInfo(),
|
||||
this._fetchBackupConfig(),
|
||||
this._fetchBackupAgents(),
|
||||
]).finally(() => {
|
||||
this._fetching = false;
|
||||
});
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
@@ -70,16 +82,13 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
|
||||
if (this.hasUpdated) {
|
||||
this._fetchBackupInfo();
|
||||
this._fetchBackupConfig();
|
||||
this._fetchBackupAgents();
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchBackupInfo() {
|
||||
const info = await fetchBackupInfo(this.hass);
|
||||
this._backups = info.backups.map((backup) => ({
|
||||
...backup,
|
||||
agent_ids: backup.agent_ids?.sort(compareAgents),
|
||||
failed_agent_ids: backup.failed_agent_ids?.sort(compareAgents),
|
||||
}));
|
||||
this._backups = info.backups;
|
||||
}
|
||||
|
||||
private async _fetchBackupConfig() {
|
||||
@@ -87,6 +96,11 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private async _fetchBackupAgents() {
|
||||
const { agents } = await fetchBackupAgentsInfo(this.hass);
|
||||
this._agents = agents.sort((a, b) => compareAgents(a.agent_id, b.agent_id));
|
||||
}
|
||||
|
||||
protected routerOptions: RouterOptions = {
|
||||
defaultPage: "overview",
|
||||
routes: {
|
||||
@@ -105,6 +119,11 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
|
||||
settings: {
|
||||
tag: "ha-config-backup-settings",
|
||||
load: () => import("./ha-config-backup-settings"),
|
||||
cache: true,
|
||||
},
|
||||
location: {
|
||||
tag: "ha-config-backup-location",
|
||||
load: () => import("./ha-config-backup-location"),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -117,13 +136,18 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
|
||||
pageEl.manager = this._manager;
|
||||
pageEl.backups = this._backups;
|
||||
pageEl.config = this._config;
|
||||
pageEl.agents = this._agents;
|
||||
pageEl.fetching = this._fetching;
|
||||
|
||||
if (
|
||||
(!changedProps || changedProps.has("route")) &&
|
||||
this._currentPage === "details"
|
||||
) {
|
||||
pageEl.backupId = this.routeTail.path.substr(1);
|
||||
if (!changedProps || changedProps.has("route")) {
|
||||
switch (this._currentPage) {
|
||||
case "details":
|
||||
pageEl.backupId = this.routeTail.path.substr(1);
|
||||
break;
|
||||
case "location":
|
||||
pageEl.agentId = this.routeTail.path.substr(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
103
src/panels/config/backup/helper/download_backup.ts
Normal file
103
src/panels/config/backup/helper/download_backup.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { LitElement } from "lit";
|
||||
import { getSignedPath } from "../../../../data/auth";
|
||||
import type { BackupConfig, BackupContent } from "../../../../data/backup";
|
||||
import {
|
||||
canDecryptBackupOnDownload,
|
||||
getBackupDownloadUrl,
|
||||
getPreferredAgentForDownload,
|
||||
} from "../../../../data/backup";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { fileDownload } from "../../../../util/file_download";
|
||||
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
|
||||
import { showDownloadDecryptedBackupDialog } from "../dialogs/show-dialog-download-decrypted-backup";
|
||||
|
||||
export const downloadBackupFile = async (
|
||||
hass: HomeAssistant,
|
||||
backupId: string,
|
||||
preferedAgent: string,
|
||||
encryptionKey?: string | null
|
||||
) => {
|
||||
const signedUrl = await getSignedPath(
|
||||
hass,
|
||||
getBackupDownloadUrl(backupId, preferedAgent, encryptionKey)
|
||||
);
|
||||
fileDownload(signedUrl.path);
|
||||
};
|
||||
|
||||
export const downloadBackup = async (
|
||||
hass: HomeAssistant,
|
||||
element: LitElement,
|
||||
backup: BackupContent,
|
||||
backupConfig?: BackupConfig,
|
||||
agentId?: string
|
||||
): Promise<void> => {
|
||||
const agentIds = Object.keys(backup.agents);
|
||||
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
|
||||
const isProtected = backup.agents[preferedAgent]?.protected;
|
||||
|
||||
if (!isProtected) {
|
||||
downloadBackupFile(hass, backup.backup_id, preferedAgent);
|
||||
return;
|
||||
}
|
||||
|
||||
const encryptionKey = backupConfig?.create_backup?.password;
|
||||
|
||||
if (!encryptionKey) {
|
||||
showDownloadDecryptedBackupDialog(element, {
|
||||
backup,
|
||||
agentId: preferedAgent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if we can decrypt it
|
||||
await canDecryptBackupOnDownload(
|
||||
hass,
|
||||
backup.backup_id,
|
||||
preferedAgent,
|
||||
encryptionKey
|
||||
);
|
||||
downloadBackupFile(hass, backup.backup_id, preferedAgent, encryptionKey);
|
||||
} catch (err: any) {
|
||||
// If encryption key is incorrect, ask for encryption key
|
||||
if (err?.code === "password_incorrect") {
|
||||
showDownloadDecryptedBackupDialog(element, {
|
||||
backup,
|
||||
agentId: preferedAgent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If decryption is not supported, ask for confirmation and download it encrypted
|
||||
if (err?.code === "decrypt_not_supported") {
|
||||
showAlertDialog(element, {
|
||||
title: hass.localize(
|
||||
"ui.panel.config.backup.dialogs.download.decryption_unsupported_title"
|
||||
),
|
||||
text: hass.localize(
|
||||
"ui.panel.config.backup.dialogs.download.decryption_unsupported"
|
||||
),
|
||||
confirm() {
|
||||
downloadBackupFile(hass, backup.backup_id, preferedAgent);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Else, show generic error
|
||||
showAlertDialog(element, {
|
||||
title: hass.localize(
|
||||
"ui.panel.config.backup.dialogs.download.error_check_title",
|
||||
{
|
||||
error: err.message,
|
||||
}
|
||||
),
|
||||
text: hass.localize(
|
||||
"ui.panel.config.backup.dialogs.download.error_check_description",
|
||||
{
|
||||
error: err.message,
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
@@ -1,15 +1,15 @@
|
||||
import "@material/mwc-button";
|
||||
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js";
|
||||
import { formatDateTime } from "../../../../common/datetime/format_date_time";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { debounce } from "../../../../common/util/debounce";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-tip";
|
||||
import "../../../../components/ha-list-item";
|
||||
import "../../../../components/ha-button-menu";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-list-item";
|
||||
import "../../../../components/ha-tip";
|
||||
import type {
|
||||
CloudStatusLoggedIn,
|
||||
SubscriptionInfo,
|
||||
@@ -32,6 +32,7 @@ import "./cloud-ice-servers-pref";
|
||||
import "./cloud-remote-pref";
|
||||
import "./cloud-tts-pref";
|
||||
import "./cloud-webhooks";
|
||||
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
|
||||
|
||||
@customElement("cloud-account")
|
||||
export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
@@ -52,7 +53,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
.narrow=${this.narrow}
|
||||
header="Home Assistant Cloud"
|
||||
>
|
||||
<ha-button-menu slot="toolbar-icon" @action=${this._deleteCloudData}>
|
||||
<ha-button-menu slot="toolbar-icon" @action=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
@@ -65,6 +66,12 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
)}
|
||||
<ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon>
|
||||
</ha-list-item>
|
||||
<ha-list-item graphic="icon">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.download_support_package"
|
||||
)}
|
||||
<ha-svg-icon slot="graphic" .path=${mdiDownload}></ha-svg-icon>
|
||||
</ha-list-item>
|
||||
</ha-button-menu>
|
||||
<div class="content">
|
||||
<ha-config-section .isWide=${this.isWide}>
|
||||
@@ -286,6 +293,16 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
}
|
||||
|
||||
private _handleMenuAction(ev) {
|
||||
switch (ev.detail.index) {
|
||||
case 0:
|
||||
this._deleteCloudData();
|
||||
break;
|
||||
case 1:
|
||||
this._downloadSupportPackage();
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteCloudData() {
|
||||
const confirm = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
@@ -316,6 +333,10 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
private async _downloadSupportPackage() {
|
||||
showSupportPackageDialog(this);
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
|
206
src/panels/config/cloud/account/dialog-cloud-support-package.ts
Normal file
206
src/panels/config/cloud/account/dialog-cloud-support-package.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import "@material/mwc-button";
|
||||
import "@material/mwc-list/mwc-list-item";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-circular-progress";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
import "../../../../components/ha-markdown-element";
|
||||
import "../../../../components/ha-md-dialog";
|
||||
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
|
||||
import "../../../../components/ha-select";
|
||||
import "../../../../components/ha-textarea";
|
||||
import { fetchSupportPackage } from "../../../../data/cloud";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { fileDownload } from "../../../../util/file_download";
|
||||
|
||||
@customElement("dialog-cloud-support-package")
|
||||
export class DialogSupportPackage extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _supportPackage?: string;
|
||||
|
||||
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||
|
||||
public showDialog() {
|
||||
this._open = true;
|
||||
this._loadSupportPackage();
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._open = false;
|
||||
this._supportPackage = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
this._dialog?.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._open) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-md-dialog open @closed=${this._dialogClosed}>
|
||||
<ha-dialog-header slot="headline">
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
@click=${this.closeDialog}
|
||||
></ha-icon-button>
|
||||
<span slot="title">Download support package</span>
|
||||
</ha-dialog-header>
|
||||
|
||||
<div slot="content">
|
||||
${this._supportPackage
|
||||
? html`<ha-markdown-element
|
||||
.content=${this._supportPackage}
|
||||
breaks
|
||||
></ha-markdown-element>`
|
||||
: html`
|
||||
<div class="progress-container">
|
||||
<ha-circular-progress indeterminate></ha-circular-progress>
|
||||
Generating preview...
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
<div class="footer" slot="actions">
|
||||
<ha-alert>
|
||||
This file may contain personal data about your home. Avoid sharing
|
||||
them with unverified or untrusted parties.
|
||||
</ha-alert>
|
||||
<hr />
|
||||
<div class="actions">
|
||||
<ha-button @click=${this.closeDialog}>Close</ha-button>
|
||||
<ha-button @click=${this._download}>Download</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
</ha-md-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _loadSupportPackage() {
|
||||
this._supportPackage = await fetchSupportPackage(this.hass);
|
||||
}
|
||||
|
||||
private async _download() {
|
||||
fileDownload(
|
||||
"data:text/plain;charset=utf-8," +
|
||||
encodeURIComponent(this._supportPackage || ""),
|
||||
"support-package.md"
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-md-dialog {
|
||||
min-width: 90vw;
|
||||
min-height: 90vh;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: calc(90vh - 260px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
ha-md-dialog {
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.progress-container {
|
||||
height: calc(100vh - 260px);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
width: calc(100% + 48px);
|
||||
margin-right: -24px;
|
||||
margin-left: -24px;
|
||||
}
|
||||
table,
|
||||
th,
|
||||
td {
|
||||
border: none;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
display: table;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
table tr {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
table > tbody > tr:nth-child(odd) {
|
||||
background-color: rgba(var(--rgb-primary-text-color), 0.04);
|
||||
}
|
||||
|
||||
table > tbody > tr > td {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
table > tbody > tr {
|
||||
-webkit-transition: background-color 0.25s ease;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
|
||||
table > tbody > tr:hover {
|
||||
background-color: rgba(var(--rgb-primary-text-color), 0.08);
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: 15px 5px;
|
||||
display: table-cell;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
border-radius: 2px;
|
||||
}
|
||||
details {
|
||||
background-color: var(--secondary-background-color);
|
||||
padding: 16px 24px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 16px;
|
||||
}
|
||||
summary {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-cloud-support-package": DialogSupportPackage;
|
||||
}
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
|
||||
export const loadSupportPackageDialog = () =>
|
||||
import("./dialog-cloud-support-package");
|
||||
|
||||
export const showSupportPackageDialog = (element: HTMLElement): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-cloud-support-package",
|
||||
dialogImport: loadSupportPackageDialog,
|
||||
dialogParams: {},
|
||||
});
|
||||
};
|
@@ -1,6 +1,6 @@
|
||||
import "@material/mwc-button";
|
||||
import "@material/mwc-list/mwc-list";
|
||||
import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js";
|
||||
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -27,6 +27,7 @@ import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../../ha-config-section";
|
||||
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
|
||||
|
||||
@customElement("cloud-login")
|
||||
export class CloudLogin extends LitElement {
|
||||
@@ -57,7 +58,7 @@ export class CloudLogin extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
header="Home Assistant Cloud"
|
||||
>
|
||||
<ha-button-menu slot="toolbar-icon" @action=${this._deleteCloudData}>
|
||||
<ha-button-menu slot="toolbar-icon" @action=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
@@ -70,6 +71,12 @@ export class CloudLogin extends LitElement {
|
||||
)}
|
||||
<ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon>
|
||||
</ha-list-item>
|
||||
<ha-list-item graphic="icon">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.download_support_package"
|
||||
)}
|
||||
<ha-svg-icon slot="graphic" .path=${mdiDownload}></ha-svg-icon>
|
||||
</ha-list-item>
|
||||
</ha-button-menu>
|
||||
<div class="content">
|
||||
<ha-config-section .isWide=${this.isWide}>
|
||||
@@ -348,6 +355,16 @@ export class CloudLogin extends LitElement {
|
||||
fireEvent(this, "flash-message-changed", { value: "" });
|
||||
}
|
||||
|
||||
private _handleMenuAction(ev) {
|
||||
switch (ev.detail.index) {
|
||||
case 0:
|
||||
this._deleteCloudData();
|
||||
break;
|
||||
case 1:
|
||||
this._downloadSupportPackage();
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteCloudData() {
|
||||
const confirm = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
@@ -377,6 +394,10 @@ export class CloudLogin extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async _downloadSupportPackage() {
|
||||
showSupportPackageDialog(this);
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
|
@@ -1073,7 +1073,14 @@ export class HaConfigDevicePage extends LitElement {
|
||||
(ent) => computeDomain(ent.entity_id) === "assist_satellite"
|
||||
);
|
||||
|
||||
const domains = this._integrations(
|
||||
device,
|
||||
this.entries,
|
||||
this.manifests
|
||||
).map((int) => int.domain);
|
||||
|
||||
if (
|
||||
!domains.includes("voip") &&
|
||||
assistSatellite &&
|
||||
assistSatelliteSupportsSetupFlow(
|
||||
this.hass.states[assistSatellite.entity_id]
|
||||
@@ -1088,12 +1095,6 @@ export class HaConfigDevicePage extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
const domains = this._integrations(
|
||||
device,
|
||||
this.entries,
|
||||
this.manifests
|
||||
).map((int) => int.domain);
|
||||
|
||||
if (domains.includes("mqtt")) {
|
||||
const mqtt = await import(
|
||||
"./device-detail/integration-elements/mqtt/device-actions"
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import "@material/mwc-button/mwc-button";
|
||||
import { mdiDelete, mdiDevices, mdiPencil } from "@mdi/js";
|
||||
import { mdiDelete, mdiDevices, mdiDrag, mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
@@ -7,7 +7,6 @@ import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-state-icon";
|
||||
import "../../../../components/ha-sortable";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type {
|
||||
@@ -82,41 +81,37 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
"ui.panel.config.energy.device_consumption.devices"
|
||||
)}
|
||||
</h3>
|
||||
<ha-sortable handle-selector=".row" @item-moved=${this._itemMoved}>
|
||||
<ha-sortable handle-selector=".handle" @item-moved=${this._itemMoved}>
|
||||
<div class="devices">
|
||||
${repeat(
|
||||
this.preferences.device_consumption,
|
||||
(device) => device.stat_consumption,
|
||||
(device) => {
|
||||
const entityState = this.hass.states[device.stat_consumption];
|
||||
return html`
|
||||
<div class="row" .device=${device}>
|
||||
<ha-state-icon
|
||||
.hass=${this.hass}
|
||||
.stateObj=${entityState}
|
||||
></ha-state-icon>
|
||||
<span class="content"
|
||||
>${device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[device.stat_consumption]
|
||||
)}</span
|
||||
>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.edit")}
|
||||
@click=${this._editDevice}
|
||||
.path=${mdiPencil}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
@click=${this._deleteDevice}
|
||||
.device=${device}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
(device) => html`
|
||||
<div class="row" .device=${device}>
|
||||
<div class="handle">
|
||||
<ha-svg-icon .path=${mdiDrag}></ha-svg-icon>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
<span class="content"
|
||||
>${device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[device.stat_consumption]
|
||||
)}</span
|
||||
>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.edit")}
|
||||
@click=${this._editDevice}
|
||||
.path=${mdiPencil}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
@click=${this._deleteDevice}
|
||||
.device=${device}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
@@ -214,7 +209,7 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
haStyle,
|
||||
energyCardStyles,
|
||||
css`
|
||||
.row {
|
||||
.handle {
|
||||
cursor: move; /* fallback if grab cursor is unsupported */
|
||||
cursor: grab;
|
||||
}
|
||||
|
@@ -23,7 +23,7 @@ import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoize from "memoize-one";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { formatShortDateTime } from "../../../common/datetime/format_date_time";
|
||||
import { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
@@ -410,7 +410,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
|
||||
minWidth: "128px",
|
||||
template: (entry) =>
|
||||
entry.created_at
|
||||
? formatShortDateTime(
|
||||
? formatShortDateTimeWithConditionalYear(
|
||||
new Date(entry.created_at * 1000),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
@@ -425,7 +425,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
|
||||
minWidth: "128px",
|
||||
template: (entry) =>
|
||||
entry.modified_at
|
||||
? formatShortDateTime(
|
||||
? formatShortDateTimeWithConditionalYear(
|
||||
new Date(entry.modified_at * 1000),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
@@ -729,7 +729,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
|
||||
</ha-md-menu-item>`;
|
||||
})}
|
||||
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
|
||||
<ha-md-menu-item @click=${this._bulkCreateLabel}>
|
||||
<ha-md-menu-item .clickAction=${this._bulkCreateLabel}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.labels.add_label")}
|
||||
</div></ha-md-menu-item
|
||||
@@ -844,7 +844,7 @@ ${
|
||||
: nothing
|
||||
}
|
||||
|
||||
<ha-md-menu-item @click=${this._enableSelected}>
|
||||
<ha-md-menu-item .clickAction=${this._enableSelected}>
|
||||
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
@@ -852,7 +852,7 @@ ${
|
||||
)}
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu-item @click=${this._disableSelected}>
|
||||
<ha-md-menu-item .clickAction=${this._disableSelected}>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiToggleSwitchOffOutline}
|
||||
@@ -865,7 +865,7 @@ ${
|
||||
</ha-md-menu-item>
|
||||
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
|
||||
|
||||
<ha-md-menu-item @click=${this._unhideSelected}>
|
||||
<ha-md-menu-item .clickAction=${this._unhideSelected}>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiEye}
|
||||
@@ -876,7 +876,7 @@ ${
|
||||
)}
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu-item @click=${this._hideSelected}>
|
||||
<ha-md-menu-item .clickAction=${this._hideSelected}>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiEyeOff}
|
||||
@@ -889,7 +889,7 @@ ${
|
||||
</ha-md-menu-item>
|
||||
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
|
||||
|
||||
<ha-md-menu-item @click=${this._removeSelected} class="warning">
|
||||
<ha-md-menu-item .clickAction=${this._removeSelected} class="warning">
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiDelete}
|
||||
@@ -1123,7 +1123,7 @@ ${
|
||||
this._selected = ev.detail.value;
|
||||
}
|
||||
|
||||
private async _enableSelected() {
|
||||
private _enableSelected = async () => {
|
||||
showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.entities.picker.enable_selected.confirm_title",
|
||||
@@ -1191,9 +1191,9 @@ ${
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private _disableSelected() {
|
||||
private _disableSelected = () => {
|
||||
showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.entities.picker.disable_selected.confirm_title",
|
||||
@@ -1213,9 +1213,9 @@ ${
|
||||
this._clearSelection();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private _hideSelected() {
|
||||
private _hideSelected = () => {
|
||||
showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.entities.picker.hide_selected.confirm_title",
|
||||
@@ -1235,16 +1235,16 @@ ${
|
||||
this._clearSelection();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private _unhideSelected() {
|
||||
private _unhideSelected = () => {
|
||||
this._selected.forEach((entity) =>
|
||||
updateEntityRegistryEntry(this.hass, entity, {
|
||||
hidden_by: null,
|
||||
})
|
||||
);
|
||||
this._clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
private async _handleBulkLabel(ev) {
|
||||
const label = ev.currentTarget.value;
|
||||
@@ -1286,7 +1286,7 @@ ${rejected
|
||||
}
|
||||
}
|
||||
|
||||
private _bulkCreateLabel() {
|
||||
private _bulkCreateLabel = () => {
|
||||
showLabelDetailDialog(this, {
|
||||
createEntry: async (values) => {
|
||||
const label = await createLabelRegistryEntry(this.hass, values);
|
||||
@@ -1294,9 +1294,9 @@ ${rejected
|
||||
return label;
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private async _removeSelected() {
|
||||
private _removeSelected = async () => {
|
||||
if (!this._entities || !this.hass) {
|
||||
return;
|
||||
}
|
||||
@@ -1369,7 +1369,7 @@ ${rejected
|
||||
this._clearSelection();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private _clearSelection() {
|
||||
this._dataTable.clearSelection();
|
||||
|
@@ -548,6 +548,13 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) {
|
||||
"./integrations/integration-panels/thread/thread-config-panel"
|
||||
),
|
||||
},
|
||||
bluetooth: {
|
||||
tag: "bluetooth-config-dashboard-router",
|
||||
load: () =>
|
||||
import(
|
||||
"./integrations/integration-panels/bluetooth/bluetooth-config-dashboard-router"
|
||||
),
|
||||
},
|
||||
application_credentials: {
|
||||
tag: "ha-config-application-credentials",
|
||||
load: () =>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user