Compare commits

..

2 Commits

Author SHA1 Message Date
Petar Petrov
f52f73ad3f format 2025-01-17 18:57:28 +02:00
Petar Petrov
196df65980 Disable chart animations if prefers-reduced-motion is enabled 2025-01-17 18:54:40 +02:00
135 changed files with 6013 additions and 6088 deletions

View File

@@ -26,7 +26,7 @@ jobs:
ref: dev
- name: Setup Node
uses: actions/setup-node@v4.2.0
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -62,7 +62,7 @@ jobs:
ref: master
- name: Setup Node
uses: actions/setup-node@v4.2.0
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -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.2.0
uses: actions/setup-node@v4.1.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.2.0
uses: actions/setup-node@v4.1.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.2.0
uses: actions/setup-node@v4.1.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.2.0
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -27,7 +27,7 @@ jobs:
ref: dev
- name: Setup Node
uses: actions/setup-node@v4.2.0
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -63,7 +63,7 @@ jobs:
ref: master
- name: Setup Node
uses: actions/setup-node@v4.2.0
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.2.0
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.2.0
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -28,7 +28,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node
uses: actions/setup-node@v4.2.0
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6.1.0
- uses: release-drafter/release-drafter@v6.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -34,7 +34,7 @@ jobs:
uses: home-assistant/actions/helpers/verify-version@master
- name: Setup Node
uses: actions/setup-node@v4.2.0
uses: actions/setup-node@v4.1.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.2.0
uses: actions/setup-node@v4.1.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.2.0
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 90 days stale policy
uses: actions/stale@v9.1.0
uses: actions/stale@v9.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90

View File

@@ -124,8 +124,6 @@ const AREAS: AreaRegistryEntry[] = [
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -137,8 +135,6 @@ const AREAS: AreaRegistryEntry[] = [
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -150,8 +146,6 @@ const AREAS: AreaRegistryEntry[] = [
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},

View File

@@ -123,8 +123,6 @@ const AREAS: AreaRegistryEntry[] = [
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -136,8 +134,6 @@ const AREAS: AreaRegistryEntry[] = [
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -149,8 +145,6 @@ const AREAS: AreaRegistryEntry[] = [
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},

View File

@@ -9,7 +9,6 @@ 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";
@@ -19,11 +18,7 @@ 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,
@@ -126,8 +121,6 @@ class UpdateAvailableCard extends LitElement {
const changelog = changelogUrl(this._updateType, this._version_latest);
const createBackupTexts = this._computeCreateBackupTexts();
return html`
<ha-card
outlined
@@ -167,30 +160,6 @@ 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"
@@ -258,48 +227,6 @@ 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"
@@ -414,22 +341,14 @@ 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!,
this._shouldCreateBackup
);
await updateHassioAddon(this.hass, this.addonSlug!);
} else if (this._updateType === "core") {
await updateCore(this.hass, this._shouldCreateBackup);
await updateCore(this.hass);
} else if (this._updateType === "os") {
await updateOS(this.hass);
} else if (this._updateType === "supervisor") {
@@ -484,17 +403,6 @@ 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;
}
`,
];
}

View File

@@ -26,7 +26,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.26.7",
"@babel/runtime": "7.26.0",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.18.4",
"@codemirror/commands": "6.8.0",
@@ -99,6 +99,8 @@
"@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",
@@ -108,15 +110,14 @@
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "5.6.0",
"element-internals-polyfill": "1.3.13",
"element-internals-polyfill": "1.3.12",
"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.14",
"intl-messageformat": "10.7.11",
"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",
@@ -125,7 +126,7 @@
"luxon": "3.5.0",
"marked": "15.0.6",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"node-vibrant": "4.0.1",
"punycode": "2.3.1",
"qr-scanner": "1.4.2",
"qrcode": "1.5.4",
@@ -152,20 +153,20 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.26.7",
"@babel/core": "7.26.0",
"@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.7",
"@babel/preset-env": "7.26.0",
"@babel/preset-typescript": "7.26.0",
"@bundle-stats/plugin-webpack-filter": "4.18.2",
"@bundle-stats/plugin-webpack-filter": "4.17.0",
"@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.2.2",
"@rspack/core": "1.2.2",
"@rspack/cli": "1.1.8",
"@rspack/core": "1.1.8",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.20",
"@types/chromecast-caf-sender": "1.0.11",
@@ -183,16 +184,16 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@typescript-eslint/eslint-plugin": "8.21.0",
"@typescript-eslint/parser": "8.21.0",
"@vitest/coverage-v8": "3.0.4",
"@typescript-eslint/eslint-plugin": "8.20.0",
"@typescript-eslint/parser": "8.20.0",
"@vitest/coverage-v8": "2.1.8",
"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.19.0",
"eslint": "9.18.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.0.1",
"eslint-config-prettier": "9.1.0",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-lit": "1.15.0",
@@ -200,7 +201,7 @@
"eslint-plugin-unused-imports": "4.1.4",
"eslint-plugin-wc": "2.2.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.0",
"fs-extra": "11.2.0",
"glob": "11.0.1",
"gulp": "5.0.0",
"gulp-brotli": "3.0.0",
@@ -210,7 +211,7 @@
"husky": "9.1.7",
"jsdom": "26.0.0",
"jszip": "3.10.1",
"lint-staged": "15.4.3",
"lint-staged": "15.3.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.5.0",
@@ -224,7 +225,7 @@
"terser-webpack-plugin": "5.3.11",
"ts-lit-plugin": "2.0.2",
"typescript": "5.7.3",
"vitest": "3.0.4",
"vitest": "2.1.8",
"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"
@@ -238,8 +239,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "1.6.3",
"@fullcalendar/daygrid": "6.1.15",
"globals": "15.14.0",
"tslib": "2.8.1"
"globals": "15.14.0"
},
"packageManager": "yarn@4.6.0"
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20250129.0"
version = "20241224.0"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"

View File

@@ -65,18 +65,6 @@ 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,

View File

@@ -1,62 +0,0 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { XAXisOption } from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../data/translation";
import {
formatDateMonth,
formatDateMonthYear,
formatDateVeryShort,
formatDateWeekdayShort,
} from "../../common/datetime/format_date";
import { formatTime } from "../../common/datetime/format_time";
export function getLabelFormatter(
locale: FrontendLocaleData,
config: HassConfig,
dayDifference = 0
) {
return (value: number | Date) => {
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);
}
// show only date for the beginning of the day
if (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0
) {
return `{bold|${formatDateVeryShort(date, locale, config)}}`;
}
return formatTime(date, locale, config);
};
}
export function getTimeAxisLabelConfig(
locale: FrontendLocaleData,
config: HassConfig,
dayDifference?: number
): XAXisOption["axisLabel"] {
return {
formatter: getLabelFormatter(locale, config, dayDifference),
rich: {
bold: {
fontWeight: "bold",
},
},
hideOverlap: true,
};
}

View File

@@ -0,0 +1,269 @@
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;
}
},
});

View File

@@ -0,0 +1,6 @@
import type { ChartEvent } from "chart.js";
export const clickIsTouch = (event: ChartEvent): boolean =>
!(event.native instanceof MouseEvent) ||
(event.native instanceof PointerEvent &&
event.native.pointerType !== "mouse");

View File

@@ -1,59 +1,79 @@
import type {
Chart,
ChartType,
ChartData,
ChartOptions,
TooltipModel,
UpdateMode,
} from "chart.js";
import type { PropertyValues } from "lit";
import { css, html, nothing, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { mdiRestart } from "@mdi/js";
import type { EChartsType } from "echarts/core";
import type { DataZoomComponentOption } from "echarts/components";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type {
ECElementEvent,
XAXisOption,
YAXisOption,
} from "echarts/types/dist/shared";
import { consume } from "@lit-labs/context";
import { fireEvent } from "../../common/dom/fire_event";
import { clamp } from "../../common/number/clamp";
import type { HomeAssistant } from "../../types";
import { debounce } from "../../common/util/debounce";
import { isMac } from "../../util/is_mac";
import "../ha-icon-button";
import type { ECOption } from "../../resources/echarts";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { Themes } from "../../data/ws-themes";
import { themesContext } from "../../data/context";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
interface Tooltip
extends Omit<TooltipModel<any>, "tooltipPosition" | "hasValue" | "getProps"> {
top: string;
left: string;
}
export interface ChartDatasetExtra {
show_legend?: boolean;
legend_label?: string;
}
@customElement("ha-chart-base")
export class HaChartBase extends LitElement {
public chart?: EChartsType;
public chart?: Chart;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: ECOption["series"] = [];
@property({ attribute: "chart-type", reflect: true })
public chartType: ChartType = "line";
@property({ attribute: false }) public options?: ECOption;
@property({ attribute: false }) public data: ChartData = { datasets: [] };
@property({ type: String }) public height?: string;
@property({ attribute: false }) public extraData?: ChartDatasetExtra[];
@property({ attribute: false }) public options?: ChartOptions;
@property({ attribute: false }) public plugins?: any[];
@property({ type: Number }) public height?: number;
@property({ attribute: false, type: Number }) public paddingYAxis = 0;
@property({ attribute: "external-hidden", type: Boolean })
public externalHidden = false;
@state()
@consume({ context: themesContext, subscribe: true })
_themes!: Themes;
@state() private _legendHeight?: number;
@state() private _tooltip?: Tooltip;
@state() private _hiddenDatasets = new Set<number>();
@state() private _showZoomHint = false;
@state() private _isZoomed = false;
private _modifierPressed = false;
private _paddingUpdateCount = 0;
private _isTouchDevice = "ontouchstart" in window;
private _paddingUpdateLock = false;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: () => this.chart?.resize(),
});
private _paddingYAxisInternal = 0;
private _loading = false;
private _datasetOrder: number[] = [];
private _reducedMotion = false;
@@ -61,105 +81,200 @@ export class HaChartBase extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("scroll", this._handleScroll, true);
this._releaseCanvas();
while (this._listeners.length) {
this._listeners.pop()!();
}
this.chart?.dispose();
}
public connectedCallback() {
super.connectedCallback();
window.addEventListener("scroll", this._handleScroll, true);
if (this.hasUpdated) {
this._releaseCanvas();
this._setupChart();
}
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
this._reducedMotion = matches;
this.chart?.setOption({ animation: !this._reducedMotion });
})
);
// Add keyboard event listeners
const handleKeyDown = (ev: KeyboardEvent) => {
if ((isMac && ev.metaKey) || (!isMac && ev.ctrlKey)) {
this._modifierPressed = true;
if (!this.options?.dataZoom) {
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
}
}
};
const handleKeyUp = (ev: KeyboardEvent) => {
if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) {
this._modifierPressed = false;
if (!this.options?.dataZoom) {
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
}
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
this._listeners.push(
() => window.removeEventListener("keydown", handleKeyDown),
() => window.removeEventListener("keyup", handleKeyUp)
);
}
public updateChart = (mode?: UpdateMode): void => {
this.chart?.update(mode);
};
protected firstUpdated() {
this._setupChart();
this.data.datasets.forEach((dataset, index) => {
if (dataset.hidden) {
this._hiddenDatasets.add(index);
}
});
}
public shouldUpdate(changedProps: PropertyValues): boolean {
if (
this._paddingUpdateLock &&
changedProps.size === 1 &&
changedProps.has("paddingYAxis")
) {
return false;
}
return true;
}
private _debouncedClearUpdates = debounce(
() => {
this._paddingUpdateCount = 0;
},
2000,
false
);
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (!this._paddingUpdateLock) {
this._paddingYAxisInternal = this.paddingYAxis;
if (changedProps.size === 1 && changedProps.has("paddingYAxis")) {
this._paddingUpdateCount++;
if (this._paddingUpdateCount > 300) {
this._paddingUpdateLock = true;
// eslint-disable-next-line
console.error(
"Detected excessive chart padding updates, possibly an infinite loop. Disabling axis padding."
);
} else {
this._debouncedClearUpdates();
}
}
}
// put the legend labels in sorted order if provided
if (changedProps.has("data")) {
this._datasetOrder = this.data.datasets.map((_, index) => index);
if (this.data?.datasets.some((dataset) => dataset.order)) {
this._datasetOrder.sort(
(a, b) =>
(this.data.datasets[a].order || 0) -
(this.data.datasets[b].order || 0)
);
}
if (this.externalHidden) {
this._hiddenDatasets = new Set();
if (this.data?.datasets) {
this.data.datasets.forEach((dataset, index) => {
if (dataset.hidden) {
this._hiddenDatasets.add(index);
}
});
}
}
}
if (!this.hasUpdated || !this.chart) {
return;
}
if (changedProps.has("_themes")) {
if (changedProps.has("plugins") || changedProps.has("chartType")) {
this._releaseCanvas();
this._setupChart();
return;
}
if (changedProps.has("data")) {
this.chart.setOption(
{ series: this.data },
{ lazyUpdate: true, replaceMerge: ["series"] }
);
if (this._hiddenDatasets.size && !this.externalHidden) {
this.data.datasets.forEach((dataset, index) => {
dataset.hidden = this._hiddenDatasets.has(index);
});
}
this.chart.data = this.data;
}
if (changedProps.has("options") || changedProps.has("_isZoomed")) {
this.chart.setOption(this._createOptions(), {
lazyUpdate: true,
// if we replace the whole object, it will reset the dataZoom
replaceMerge: [
"xAxis",
"yAxis",
"dataZoom",
"dataset",
"tooltip",
"legend",
"grid",
"visualMap",
],
});
if (changedProps.has("options") && !this.chart.isZoomedOrPanned()) {
// this resets the chart zoom because min/max scales changed
// so we only do it if the user is not zooming or panning
this.chart.options = this._createOptions();
}
this.chart.update("none");
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has("data") || changedProperties.has("options")) {
if (this.options?.plugins?.legend?.display) {
this._legendHeight =
this.renderRoot.querySelector(".chart-legend")?.clientHeight;
} else {
this._legendHeight = 0;
}
}
}
protected render() {
return html`
${this.options?.plugins?.legend?.display === true
? html`<div class="chart-legend">
<ul>
${this._datasetOrder.map((index) => {
const dataset = this.data.datasets[index];
return this.extraData?.[index]?.show_legend === false
? nothing
: html`<li
.datasetIndex=${index}
@click=${this._legendClick}
class=${classMap({
hidden: this._hiddenDatasets.has(index),
})}
.title=${this.extraData?.[index]?.legend_label ??
dataset.label}
>
<div
class="bullet"
style=${styleMap({
backgroundColor: dataset.backgroundColor as string,
borderColor: dataset.borderColor as string,
})}
></div>
<div class="label">
${this.extraData?.[index]?.legend_label ??
dataset.label}
</div>
</li>`;
})}
</ul>
</div>`
: ""}
<div
class="chart-container"
style=${styleMap({
height: this.height ?? `${this._getDefaultHeight()}px`,
height: `${this.height ?? this._getDefaultHeight()}px`,
"padding-left": `${this._paddingYAxisInternal}px`,
"padding-right": 0,
"padding-inline-start": `${this._paddingYAxisInternal}px`,
"padding-inline-end": 0,
})}
@wheel=${this._handleWheel}
@wheel=${this._handleChartScroll}
>
<div class="chart"></div>
${this._isZoomed
<canvas
class=${classMap({
"not-zoomed": !this._isZoomed,
})}
></canvas>
<div
class="zoom-hint ${classMap({
visible: this._showZoomHint,
})}"
>
<div>
${isMac
? this.hass.localize("ui.components.history_charts.zoom_hint_mac")
: this.hass.localize("ui.components.history_charts.zoom_hint")}
</div>
</div>
${this._isZoomed && this.chartType !== "timeline"
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@@ -169,135 +284,260 @@ export class HaChartBase extends LitElement {
)}
></ha-icon-button>`
: nothing}
${this._tooltip
? html`<div
class="chart-tooltip ${classMap({
[this._tooltip.yAlign]: true,
})}"
style=${styleMap({
top: this._tooltip.top,
left: this._tooltip.left,
})}
>
<div class="title">${this._tooltip.title}</div>
${this._tooltip.beforeBody
? html`<div class="before-body">
${this._tooltip.beforeBody}
</div>`
: ""}
<div>
<ul>
${this._tooltip.body.map(
(item, i) =>
html`<li>
<div
class="bullet"
style=${styleMap({
backgroundColor: this._tooltip!.labelColors[i]
.backgroundColor as string,
borderColor: this._tooltip!.labelColors[i]
.borderColor as string,
})}
></div>
${item.lines.join("\n")}
</li>`
)}
</ul>
</div>
${this._tooltip.footer.length
? html`<div class="footer">
${this._tooltip.footer.map((item) => html`${item}<br />`)}
</div>`
: ""}
</div>`
: ""}
</div>
`;
}
private _loading = false;
private async _setupChart() {
if (this._loading) return;
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
const ctx: CanvasRenderingContext2D = this.renderRoot
.querySelector("canvas")!
.getContext("2d")!;
this._loading = true;
try {
if (this.chart) {
this.chart.dispose();
}
const echarts = (await import("../../resources/echarts")).default;
// eslint-disable-next-line @typescript-eslint/naming-convention
const ChartConstructor = (await import("../../resources/chartjs")).Chart;
this.chart = echarts.init(
container,
this._themes.darkMode ? "dark" : "light"
const computedStyles = getComputedStyle(this);
ChartConstructor.defaults.borderColor =
computedStyles.getPropertyValue("--divider-color");
ChartConstructor.defaults.color = computedStyles.getPropertyValue(
"--secondary-text-color"
);
this.chart.on("legendselectchanged", (params: any) => {
if (this.externalHidden) {
const isSelected = params.selected[params.name];
if (isSelected) {
fireEvent(this, "dataset-unhidden", { name: params.name });
} else {
fireEvent(this, "dataset-hidden", { name: params.name });
}
}
ChartConstructor.defaults.font.family =
computedStyles.getPropertyValue("--mdc-typography-body1-font-family") ||
computedStyles.getPropertyValue("--mdc-typography-font-family") ||
"Roboto, Noto, sans-serif";
this.chart = new ChartConstructor(ctx, {
type: this.chartType,
data: this.data,
options: this._createOptions(),
plugins: this._createPlugins(),
});
this.chart.on("datazoom", (e: any) => {
const { start, end } = e.batch?.[0] ?? e;
this._isZoomed = start !== 0 || end !== 100;
});
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
});
this.chart.on("mousemove", (e: ECElementEvent) => {
if (e.componentType === "series" && e.componentSubType === "custom") {
// custom series do not support cursor style so we need to set it manually
this.chart?.getZr()?.setCursorStyle("default");
}
});
this.chart.setOption({ ...this._createOptions(), series: this.data });
} finally {
this._loading = false;
}
}
private _getDataZoomConfig(): DataZoomComponentOption | undefined {
const xAxis = (this.options?.xAxis?.[0] ??
this.options?.xAxis) as XAXisOption;
const yAxis = (this.options?.yAxis?.[0] ??
this.options?.yAxis) as YAXisOption;
if (xAxis.type === "value" && yAxis.type === "category") {
// vertical data zoom doesn't work well in this case and horizontal is pointless
return undefined;
}
private _createOptions(): ChartOptions {
const modifierKey = isMac ? "meta" : "ctrl";
return {
id: "dataZoom",
type: "inside",
orient: "horizontal",
filterMode: "none",
moveOnMouseMove: this._isZoomed,
preventDefaultMouseMove: this._isZoomed,
zoomLock: !this._isTouchDevice && !this._modifierPressed,
maintainAspectRatio: false,
animation: this._reducedMotion ? false : { duration: 500 },
...this.options,
plugins: {
...this.options?.plugins,
tooltip: {
...this.options?.plugins?.tooltip,
enabled: false,
external: (context) => this._handleTooltip(context),
},
legend: {
...this.options?.plugins?.legend,
display: false,
},
zoom: {
...this.options?.plugins?.zoom,
pan: {
enabled: true,
},
zoom: {
pinch: {
enabled: true,
},
drag: {
enabled: true,
modifierKey,
threshold: 2,
},
wheel: {
enabled: true,
modifierKey,
speed: 0.05,
},
mode:
this.chartType !== "timeline" &&
(this.options?.scales?.y as any)?.type === "category"
? "y"
: "x",
onZoomComplete: () => {
const isZoomed = this.chart?.isZoomedOrPanned() ?? false;
if (this._isZoomed && !isZoomed) {
setTimeout(() => {
// make sure the scales are properly reset after full zoom out
// they get bugged when zooming in/out multiple times and panning
this.chart?.resetZoom();
});
}
this._isZoomed = isZoomed;
},
},
limits: {
x: {
min: "original",
max: (this.options?.scales?.x as any)?.max ?? "original",
},
y: {
min: "original",
max: "original",
},
},
},
},
};
}
private _createOptions(): ECOption {
const darkMode = this._themes.darkMode ?? false;
const options = {
backgroundColor: "transparent",
animation: !this._reducedMotion,
darkMode,
aria: {
show: true,
},
dataZoom: this._getDataZoomConfig(),
...this.options,
legend: this.options?.legend
? {
// we should create our own theme but this is a quick fix for now
inactiveColor: darkMode ? "#444" : "#ccc",
...this.options.legend,
private _createPlugins() {
return [
...(this.plugins || []),
{
id: "resizeHook",
resize: (chart: Chart) => {
if (!this.height) {
// lock the height
// this removes empty space below the chart
this.height = chart.height;
}
: undefined,
};
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
if (isMobile && options.tooltip) {
// mobile charts are full width so we need to confine the tooltip to the chart
const tooltips = Array.isArray(options.tooltip)
? options.tooltip
: [options.tooltip];
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
});
options.tooltip = tooltips;
}
return options;
},
legend: {
...this.options?.plugins?.legend,
display: false,
},
},
];
}
private _getDefaultHeight() {
return Math.max(this.clientWidth / 2, 400);
return this.clientWidth / 2;
}
private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
private _handleChartScroll(ev: MouseEvent) {
const modifier = isMac ? "metaKey" : "ctrlKey";
this._tooltip = undefined;
if (!ev[modifier] && !this._showZoomHint) {
this._showZoomHint = true;
setTimeout(() => {
this._showZoomHint = false;
}, 1000);
}
}
private _handleWheel(e: WheelEvent) {
// if the window is not focused, we don't receive the keydown events but scroll still works
if (!this.options?.dataZoom) {
const modifierPressed = (isMac && e.metaKey) || (!isMac && e.ctrlKey);
if (modifierPressed) {
e.preventDefault();
private _legendClick(ev) {
if (!this.chart) {
return;
}
const index = ev.currentTarget.datasetIndex;
if (this.chart.isDatasetVisible(index)) {
this.chart.setDatasetVisibility(index, false);
this._hiddenDatasets.add(index);
if (this.externalHidden) {
fireEvent(this, "dataset-hidden", {
index,
});
}
if (modifierPressed !== this._modifierPressed) {
this._modifierPressed = modifierPressed;
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
} else {
this.chart.setDatasetVisibility(index, true);
this._hiddenDatasets.delete(index);
if (this.externalHidden) {
fireEvent(this, "dataset-unhidden", {
index,
});
}
}
this.chart.update("none");
this.requestUpdate("_hiddenDatasets");
}
private _handleTooltip(context: {
chart: Chart;
tooltip: TooltipModel<any>;
}) {
if (context.tooltip.opacity === 0) {
this._tooltip = undefined;
return;
}
const boundingBox = this.getBoundingClientRect();
this._tooltip = {
...context.tooltip,
top:
boundingBox.y +
(this._legendHeight || 0) +
context.tooltip.caretY +
12 +
"px",
left:
clamp(
boundingBox.x + context.tooltip.caretX,
boundingBox.x + 100,
boundingBox.x + boundingBox.width - 100
) -
100 +
"px",
};
}
private _releaseCanvas() {
// release the canvas memory to prevent
// safari from running out of memory.
if (this.chart) {
this.chart.destroy();
}
}
private _handleZoomReset() {
this.chart?.resetZoom();
}
private _handleScroll = () => {
this._tooltip = undefined;
};
static styles = css`
:host {
display: block;
@@ -305,11 +545,124 @@ export class HaChartBase extends LitElement {
}
.chart-container {
position: relative;
}
canvas {
max-height: var(--chart-max-height, 400px);
}
.chart {
canvas.not-zoomed {
/* allow scrolling if the chart is not zoomed */
touch-action: pan-y !important;
}
.chart-legend {
text-align: center;
}
.chart-legend li {
cursor: pointer;
display: inline-grid;
grid-auto-flow: column;
padding: 0 8px;
box-sizing: border-box;
align-items: center;
color: var(--secondary-text-color);
}
.chart-legend .hidden {
text-decoration: line-through;
}
.chart-legend .label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.chart-legend .bullet,
.chart-tooltip .bullet {
border-width: 1px;
border-style: solid;
border-radius: 50%;
display: inline-block;
height: 16px;
margin-right: 6px;
width: 16px;
flex-shrink: 0;
box-sizing: border-box;
margin-inline-end: 6px;
margin-inline-start: initial;
direction: var(--direction);
}
.chart-tooltip .bullet {
align-self: baseline;
}
.chart-tooltip {
padding: 8px;
font-size: 90%;
position: fixed;
background: rgba(80, 80, 80, 0.9);
color: white;
border-radius: 4px;
pointer-events: none;
z-index: 1;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
width: 200px;
box-sizing: border-box;
direction: var(--direction);
}
.chart-legend ul,
.chart-tooltip ul {
display: inline-block;
padding: 0 0px;
margin: 8px 0 0 0;
width: 100%;
height: 100%;
}
.chart-tooltip ul {
margin: 0 4px;
}
.chart-tooltip li {
display: flex;
white-space: pre-line;
word-break: break-word;
align-items: center;
line-height: 16px;
padding: 4px 0;
}
.chart-tooltip .title {
text-align: center;
font-weight: 500;
word-break: break-word;
direction: ltr;
}
.chart-tooltip .footer {
font-weight: 500;
}
.chart-tooltip .before-body {
text-align: center;
font-weight: 300;
word-break: break-all;
}
.zoom-hint {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 500ms cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.zoom-hint.visible {
opacity: 1;
}
.zoom-hint > div {
color: white;
font-size: 1.5em;
font-weight: 500;
padding: 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
box-shadow: 0 0 32px 32px rgba(0, 0, 0, 0.3);
}
.zoom-reset {
position: absolute;
@@ -329,8 +682,7 @@ declare global {
"ha-chart-base": HaChartBase;
}
interface HASSDomEvents {
"dataset-hidden": { name: string };
"dataset-unhidden": { name: string };
"chart-click": ECElementEvent;
"dataset-hidden": { index: number };
"dataset-unhidden": { index: number };
}
}

View File

@@ -3,7 +3,6 @@ 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;
@@ -69,12 +68,15 @@ 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() {
@@ -475,7 +477,7 @@ export class HaSankeyChart extends LitElement {
(node) =>
NODE_WIDTH +
TEXT_PADDING +
(node.label ? measureTextWidth(node.label, FONT_SIZE) : 0)
(node.label ? this._getTextWidth(node.label) : 0)
)
)
: 0;
@@ -490,6 +492,18 @@ 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
@@ -499,7 +513,7 @@ export class HaSankeyChart extends LitElement {
longest.length > current.length ? longest : current,
""
);
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
const wordWidth = this._getTextWidth(longestWord);
return Math.min(FONT_SIZE, (labelWidth / wordWidth) * FONT_SIZE);
}

View File

@@ -1,27 +1,19 @@
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 { differenceInDays } from "date-fns";
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 type { ECOption } from "../../resources/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,
formatNumber,
} from "../../common/number/format_number";
import { getTimeAxisLabelConfig } from "./axis-label";
import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { clickIsTouch } from "./click_is_touch";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
@@ -62,17 +54,15 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
@property({ type: String }) public height?: string;
@state() private _chartData: LineSeriesOption[] = [];
@state() private _chartData?: ChartData<"line">;
@state() private _entityIds: string[] = [];
private _datasetToDataIndex: number[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: ChartOptions;
@state() private _yWidth = 25;
@state() private _yWidth = 0;
private _chartTime: Date = new Date();
@@ -82,54 +72,171 @@ export class StateHistoryChartLine extends LitElement {
.hass=${this.hass}
.data=${this._chartData}
.options=${this._chartOptions}
.height=${this.height}
style=${styleMap({ height: this.height })}
.paddingYAxis=${this.paddingYAxis - this._yWidth}
chart-type="line"
></ha-chart-base>
`;
}
private _renderTooltip(params) {
return params
.map((param, index: number) => {
let value = `${formatNumber(
param.value[1] as number,
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._entityIds[param.seriesIndex]]
)
)} ${this.unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
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;
}
const time =
index === 0
? formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}
`;
})
.join("<br>");
}
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") ||
@@ -141,133 +248,13 @@ 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 dayDifference = differenceInDays(this.endTime, this.startTime);
const rtl = computeRTL(this.hass);
const splitLineStyle = this.hass.themes?.darkMode
? { opacity: 0.15 }
: {};
this._chartOptions = {
xAxis: {
type: "time",
min: this.startTime,
max: this.endTime,
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
axisLine: {
show: false,
},
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
min: this.fitYData ? this.minYAxis : undefined,
max: this.fitYData ? this.maxYAxis : undefined,
position: rtl ? "right" : "left",
scale: true,
nameGap: 2,
nameTextStyle: {
align: "left",
},
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
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,
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: LineSeriesOption[] = [];
const datasets: ChartDataset<"line">[] = [];
const entityIds: string[] = [];
const datasetToDataIndex: number[] = [];
if (entityStates.length === 0) {
@@ -283,7 +270,7 @@ export class StateHistoryChartLine extends LitElement {
// array containing [value1, value2, etc]
let prevValues: any[] | null = null;
const data: LineSeriesOption[] = [];
const data: ChartDataset<"line">[] = [];
const pushData = (timestamp: Date, datavalues: any[] | null) => {
if (!datavalues) return;
@@ -300,9 +287,9 @@ 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([timestamp, prevValues[i]]);
d.data.push({ x: timestamp.getTime(), y: prevValues[i] });
}
d.data!.push([timestamp, datavalues[i]]);
d.data.push({ x: timestamp.getTime(), y: datavalues[i] });
});
prevValues = datavalues;
};
@@ -313,26 +300,13 @@ export class StateHistoryChartLine extends LitElement {
colorIndex++;
}
data.push({
id: nameY,
label: nameY,
fill: fill ? "origin" : false,
borderColor: color,
backgroundColor: color + "7F",
stepped: "before",
pointRadius: 0,
data: [],
type: "line",
cursor: "default",
name: nameY,
color,
symbol: "circle",
step: "end",
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);
@@ -350,16 +324,12 @@ export class StateHistoryChartLine extends LitElement {
const isHeating =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === "heat"
entityState.attributes?.hvac_action === "heating"
: (entityState: LineChartState) => entityState.state === "heat";
const isCooling =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === "cool"
entityState.attributes?.hvac_action === "cooling"
: (entityState: LineChartState) => entityState.state === "cool";
const hasHeat = states.states.some(isHeating);
@@ -605,7 +575,9 @@ export class StateHistoryChartLine extends LitElement {
Array.prototype.push.apply(datasets, data);
});
this._chartData = datasets;
this._chartData = {
datasets,
};
this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex;
}

View File

@@ -1,28 +1,19 @@
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 { differenceInDays } from "date-fns";
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 { 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";
import { getTimeAxisLabelConfig } from "./axis-label";
import type { TimeLineData } from "./timeline-chart/const";
import { computeTimelineColor } from "./timeline-chart/timeline-color";
import { clickIsTouch } from "./click_is_touch";
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@@ -53,9 +44,9 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false, type: Number }) public chartIndex?;
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartData?: ChartData<"timeline">;
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: ChartOptions<"timeline">;
@state() private _yWidth = 0;
@@ -65,97 +56,20 @@ export class StateHistoryChartTimeline extends LitElement {
return html`
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData}
@chart-click=${this._handleChartClick}
.options=${this._chartOptions}
.height=${this.data.length * 30 + 30}
.paddingYAxis=${this.paddingYAxis - this._yWidth}
chart-type="timeline"
></ha-chart-base>
`;
}
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 } = Array.isArray(params)
? params[0]
: params;
const title = `<h4 style="text-align: center; margin: 0;">${value![0]}</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 (!this.hasUpdated) {
this._createOptions();
}
if (
changedProps.has("startTime") ||
changedProps.has("endTime") ||
@@ -169,12 +83,9 @@ export class StateHistoryChartTimeline extends LitElement {
}
if (
!this.hasUpdated ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
changedProps.has("showNames")
) {
this._createOptions();
}
@@ -182,79 +93,144 @@ export class StateHistoryChartTimeline extends LitElement {
private _createOptions() {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 70 : 165;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(this.hass);
const dayDifference = differenceInDays(this.endTime, this.startTime);
this._chartOptions = {
xAxis: {
type: "time",
min: this.startTime,
max: this.endTime,
axisTick: {
show: true,
lineStyle: {
opacity: 0.4,
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",
},
},
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: "category",
inverse: true,
position: rtl ? "right" : "left",
triggerEvent: true,
axisTick: {
show: false,
},
axisLine: {
show: false,
},
axisLabel: {
show: showNames,
width: labelWidth - labelMargin,
overflow: "truncate",
margin: labelMargin,
formatter: (label: string) => {
const width = Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
);
if (width > this._yWidth) {
this._yWidth = width;
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);
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
hideOverlap: true,
position: computeRTL(this.hass) ? "right" : "left",
},
},
grid: {
top: 10,
bottom: 30,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
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,
},
},
tooltip: {
appendTo: document.body,
formatter: this._renderTooltip,
// @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
},
};
}
@@ -270,7 +246,8 @@ export class StateHistoryChartTimeline extends LitElement {
this._chartTime = new Date();
const startTime = this.startTime;
const endTime = this.endTime;
const datasets: CustomSeriesOption[] = [];
const labels: string[] = [];
const datasets: ChartDataset<"timeline">[] = [];
const names = this.names || {};
// stateHistory is a list of lists of sorted state objects
stateHistory.forEach((stateInfo) => {
@@ -281,7 +258,7 @@ export class StateHistoryChartTimeline extends LitElement {
const entityDisplay: string =
names[stateInfo.entity_id] || stateInfo.name;
const dataRow: unknown[] = [];
const dataRow: TimeLineData[] = [];
stateInfo.data.forEach((entityState) => {
let newState: string | null = entityState.state;
const timeStamp = new Date(entityState.last_changed);
@@ -300,23 +277,15 @@ 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({
value: [
entityDisplay,
prevLastChanged,
newLastChanged,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
start: prevLastChanged,
end: newLastChanged,
label: locState,
color: computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
),
});
prevState = newState;
@@ -326,51 +295,28 @@ export class StateHistoryChartTimeline extends LitElement {
});
if (prevState !== null) {
const color = computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
);
dataRow.push({
value: [
entityDisplay,
prevLastChanged,
endTime,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
start: prevLastChanged,
end: endTime,
label: locState,
color: computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
),
});
}
datasets.push({
data: dataRow,
name: entityDisplay,
dimensions: ["index", "start", "end", "name", "color", "textColor"],
type: "custom",
encode: {
x: [1, 2],
y: 0,
itemName: 3,
},
renderItem: this._renderItem,
label: stateInfo.entity_id,
});
labels.push(entityDisplay);
});
this._chartData = datasets;
}
private _handleChartClick(e: CustomEvent<ECElementEvent>): void {
if (e.detail.targetType === "axisLabel") {
const dataset = this.data[e.detail.dataIndex];
if (dataset) {
fireEvent(this, "hass-more-info", {
entityId: dataset.entity_id,
});
}
}
this._chartData = {
labels: labels,
datasets: datasets,
};
}
static styles = css`

View File

@@ -69,8 +69,6 @@ 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;
@@ -153,7 +151,6 @@ 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> `;
}
@@ -277,8 +274,7 @@ export class StateHistoryCharts extends LitElement {
static styles = css`
:host {
display: flex;
flex-direction: column;
display: block;
/* height of single timeline chart = 60px */
min-height: 60px;
}
@@ -299,7 +295,6 @@ export class StateHistoryCharts extends LitElement {
.entry-container {
width: 100%;
flex: 1;
}
.entry-container:hover {

View File

@@ -1,15 +1,21 @@
import type {
ChartData,
ChartDataset,
ChartOptions,
ChartType,
} from "chart.js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type {
BarSeriesOption,
LineSeriesOption,
} from "echarts/types/dist/shared";
import { styleMap } from "lit/directives/style-map";
import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import {
formatNumber,
numberFormatToLocale,
getNumberFormatOptions,
} from "../../common/number/format_number";
import type {
Statistics,
StatisticsMetaData,
@@ -19,18 +25,13 @@ import {
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
import { computeRTL } from "../../common/util/compute_rtl";
import type { ECOption } from "../../resources/echarts";
import {
formatNumber,
getNumberFormatOptions,
} from "../../common/number/format_number";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { getTimeAxisLabelConfig } from "./axis-label";
import type { ChartDatasetExtra } from "./ha-chart-base";
import { clickIsTouch } from "./click_is_touch";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -61,7 +62,7 @@ export class StatisticsChart extends LitElement {
@property({ attribute: false, type: Array })
public statTypes: StatisticType[] = ["sum", "min", "mean", "max"];
@property({ attribute: false }) public chartType: "line" | "bar" = "line";
@property({ attribute: false }) public chartType: ChartType = "line";
@property({ attribute: false, type: Number }) public minYAxis?: number;
@@ -83,18 +84,13 @@ export class StatisticsChart extends LitElement {
@property() public period?: string;
@property({ attribute: "days-to-show", type: Number })
public daysToShow?: number;
@state() private _chartData: ChartData = { datasets: [] };
@property({ type: String }) public height?: string;
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
@state() private _legendData: string[] = [];
@state() private _chartDatasetExtra: ChartDatasetExtra[] = [];
@state() private _statisticIds: string[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: ChartOptions;
@state() private _hiddenStats = new Set<string>();
@@ -105,14 +101,8 @@ export class StatisticsChart extends LitElement {
}
public willUpdate(changedProps: PropertyValues) {
if (
changedProps.has("statisticsData") ||
changedProps.has("statTypes") ||
changedProps.has("chartType") ||
changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats")
) {
this._generateData();
if (changedProps.has("legendMode")) {
this._hiddenStats.clear();
}
if (
!this.hasUpdated ||
@@ -123,11 +113,19 @@ export class StatisticsChart extends LitElement {
changedProps.has("maxYAxis") ||
changedProps.has("fitYData") ||
changedProps.has("logarithmicScale") ||
changedProps.has("hideLegend") ||
changedProps.has("_legendData")
changedProps.has("hideLegend")
) {
this._createOptions();
}
if (
changedProps.has("statisticsData") ||
changedProps.has("statTypes") ||
changedProps.has("chartType") ||
changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats")
) {
this._generateData();
}
}
public firstUpdated() {
@@ -159,113 +157,145 @@ export class StatisticsChart extends LitElement {
return html`
<ha-chart-base
external-hidden
.hass=${this.hass}
.data=${this._chartData}
.extraData=${this._chartDatasetExtra}
.options=${this._chartOptions}
.height=${this.height}
style=${styleMap({ height: this.height })}
external-hidden
.chartType=${this.chartType}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
></ha-chart-base>
`;
}
private _datasetHidden(ev: CustomEvent) {
this._hiddenStats.add(ev.detail.name);
private _datasetHidden(ev) {
ev.stopPropagation();
this._hiddenStats.add(this._statisticIds[ev.detail.index]);
this.requestUpdate("_hiddenStats");
}
private _datasetUnhidden(ev: CustomEvent) {
this._hiddenStats.delete(ev.detail.name);
private _datasetUnhidden(ev) {
ev.stopPropagation();
this._hiddenStats.delete(this._statisticIds[ev.detail.index]);
this.requestUpdate("_hiddenStats");
}
private _renderTooltip = (params: any) =>
params
.map((param, index: number) => {
const value = `${formatNumber(
// max series can have 3 values, as the second value is the max-min to form a band
(param.value[2] ?? param.value[1]) as number,
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._statisticIds[param.seriesIndex]]
)
)} ${this.unit}`;
const time =
index === 0
? formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}
`;
})
.join("<br>");
private _createOptions() {
const splitLineStyle = this.hass.themes?.darkMode ? { opacity: 0.15 } : {};
const dayDifference = this.daysToShow ?? 1;
private _createOptions(unit?: string) {
this._chartOptions = {
xAxis: {
type: "time",
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
axisLine: {
show: false,
},
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
parsing: false,
interaction: {
mode: "nearest",
axis: "x",
},
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
nameGap: 2,
nameTextStyle: {
align: "left",
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,
},
},
position: computeRTL(this.hass) ? "right" : "left",
// @ts-ignore
scale: this.chartType !== "bar",
min: this.fitYData ? undefined : this.minYAxis,
max: this.fitYData ? undefined : this.maxYAxis,
splitLine: {
show: true,
lineStyle: splitLineStyle,
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,
},
},
legend: {
show: !this.hideLegend,
icon: "circle",
padding: [20, 0],
data: this._legendData,
plugins: {
tooltip: {
callbacks: {
label: (context) =>
`${context.dataset.label}: ${formatNumber(
context.parsed.y,
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,
},
},
},
grid: {
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
left: 20,
right: 1,
bottom: 0,
containLabel: true,
elements: {
line: {
tension: 0.4,
cubicInterpolationMode: "monotone",
borderWidth: 1.5,
},
bar: { borderWidth: 1.5, borderRadius: 4 },
point: {
hitRadius: 50,
},
},
tooltip: {
trigger: "axis",
appendTo: document.body,
formatter: this._renderTooltip,
// @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];
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
}
}
},
};
}
@@ -295,8 +325,8 @@ export class StatisticsChart extends LitElement {
let colorIndex = 0;
const statisticsData = Object.entries(this.statisticsData);
const totalDataSets: typeof this._chartData = [];
const legendData: { name: string; color: string }[] = [];
const totalDataSets: ChartDataset<"line">[] = [];
const totalDatasetExtras: ChartDatasetExtra[] = [];
const statisticIds: string[] = [];
let endTime: Date;
@@ -342,19 +372,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: (LineSeriesOption | BarSeriesOption)[] = [];
const statLegendData: { name: string; color: string }[] = [];
const statDataSets: ChartDataset<"line">[] = [];
const statDatasetExtras: ChartDatasetExtra[] = [];
const pushData = (
start: Date,
end: Date,
dataValues: (number | null)[][]
dataValues: (number | null)[] | null
) => {
if (!dataValues.length) return;
if (!dataValues) 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.
@@ -369,10 +399,11 @@ 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([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
d.data.push({ x: prevEndTime.getTime(), y: prevValues[i]! });
// @ts-expect-error
d.data.push({ x: prevEndTime.getTime(), y: null });
}
d.data!.push([start, ...dataValues[i]!]);
d.data.push({ x: start.getTime(), y: dataValues[i]! });
});
prevValues = dataValues;
prevEndTime = end;
@@ -407,63 +438,49 @@ export class StatisticsChart extends LitElement {
})
: this.statTypes;
let displayedLegend = false;
let displayed_legend = false;
sortedTypes.forEach((type) => {
if (statisticsHaveType(stats, type)) {
const band = drawBands && (type === "min" || type === "max");
if (!this.hideLegend) {
const showLegend = hasMean
const show_legend = hasMean
? type === "mean"
: displayedLegend === false;
if (showLegend) {
statLegendData.push({ name, color });
}
displayedLegend = displayedLegend || showLegend;
: displayed_legend === false;
statDatasetExtras.push({
legend_label: name,
show_legend,
});
displayed_legend = displayed_legend || show_legend;
}
statTypes.push(type);
const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`,
type: this.chartType,
cursor: "default",
data: [],
name: name
statDataSets.push({
label: name
? `${name} (${this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
)})`
)})
`
: this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
),
symbol: "circle",
symbolSize: 0,
lineStyle: {
width: 1.5,
},
itemStyle:
this.chartType === "bar"
? {
borderRadius: [4, 4, 0, 0],
borderColor:
band && hasMean
? color + (this.hideLegend ? "00" : "7F")
: color,
borderWidth: 1.5,
}
: undefined,
color: band ? color + "3F" : color + "7F",
};
if (band && this.chartType === "line") {
series.stack = `band-${statistic_id}`;
(series as LineSeriesOption).symbol = "none";
(series as LineSeriesOption).lineStyle = {
opacity: 0,
};
if (drawBands && type === "max") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
}
statDataSets.push(series);
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,
});
statisticIds.push(statistic_id);
}
});
@@ -477,55 +494,37 @@ export class StatisticsChart extends LitElement {
return;
}
prevDate = startDate;
const dataValues: (number | null)[][] = [];
const dataValues: (number | null)[] = [];
statTypes.forEach((type) => {
const val: (number | null)[] = [];
let val: number | null | undefined;
if (type === "sum") {
if (firstSum === null || firstSum === undefined) {
val.push(0);
val = 0;
firstSum = stat.sum;
} else {
val.push((stat.sum || 0) - firstSum);
val = (stat.sum || 0) - firstSum;
}
} else if (type === "max" && this.chartType === "line") {
const max = stat.max || 0;
val.push(max - (stat.min || 0));
val.push(max);
} else {
val.push(stat[type] ?? null);
val = stat[type];
}
dataValues.push(val);
dataValues.push(val ?? null);
});
if (!this._hiddenStats.has(name)) {
pushData(startDate, new Date(stat.end), dataValues);
}
pushData(startDate, new Date(stat.end), dataValues);
});
// Concat two arrays
Array.prototype.push.apply(totalDataSets, statDataSets);
Array.prototype.push.apply(legendData, statLegendData);
Array.prototype.push.apply(totalDatasetExtras, statDatasetExtras);
});
if (unit) {
this.unit = unit;
this._createOptions(unit);
}
legendData.forEach(({ name, color }) => {
// Add an empty series for the legend
totalDataSets.push({
id: name + "-legend",
name: name,
color,
type: this.chartType,
data: [],
});
});
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._chartData = {
datasets: totalDataSets,
};
this._chartDatasetExtra = totalDatasetExtras;
this._statisticIds = statisticIds;
}

View File

@@ -0,0 +1,22 @@
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;
};
}
}

View File

@@ -0,0 +1,63 @@
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 };
}
}

View File

@@ -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: {

View File

@@ -0,0 +1,255 @@
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);
}
}

View File

@@ -33,10 +33,9 @@ export class HaAssistChip extends MdAssistChip {
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]),
::slotted([slot="trailing-icon"]) {
::slotted([slot="trailingIcon"]) {
display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
font-size: var(--_label-text-size) !important;
}
.trailing.icon ::slotted(*),

View File

@@ -178,7 +178,7 @@ class HaEntityStatePicker extends LitElement {
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter="button.trailing.action"
>
<ha-chip-set>
${repeat(
@@ -195,7 +195,12 @@ class HaEntityStatePicker extends LitElement {
.label=${label}
selected
>
<ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon>
<ha-svg-icon
slot="icon"
.path=${mdiDrag}
data-handle
></ha-svg-icon>
${label}
</ha-input-chip>
`;

View File

@@ -276,8 +276,6 @@ export class HaAreaPicker extends LitElement {
icon: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -296,8 +294,6 @@ export class HaAreaPicker extends LitElement {
icon: "mdi:plus",
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -382,8 +378,6 @@ export class HaAreaPicker extends LitElement {
picture: null,
labels: [],
aliases: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -402,8 +396,6 @@ export class HaAreaPicker extends LitElement {
picture: null,
labels: [],
aliases: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},

View File

@@ -532,7 +532,7 @@ export class HaAssistChat extends LitElement {
float: var(--float-end);
text-align: right;
border-bottom-right-radius: 0px;
background-color: var(--chat-background-color-user, var(--primary-color));
background-color: var(--primary-color);
color: var(--text-primary-color);
direction: var(--direction);
}
@@ -543,10 +543,7 @@ export class HaAssistChat extends LitElement {
margin-inline-start: initial;
float: var(--float-start);
border-bottom-left-radius: 0px;
background-color: var(
--chat-background-color-hass,
var(--secondary-background-color)
);
background-color: var(--secondary-background-color);
color: var(--primary-text-color);
direction: var(--direction);

View File

@@ -337,7 +337,6 @@ export class HaBaseTimeInput extends LitElement {
}
.time-input-wrap {
display: flex;
flex: 1;
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
overflow: hidden;
position: relative;
@@ -346,7 +345,6 @@ export class HaBaseTimeInput extends LitElement {
}
ha-textfield {
width: 55px;
flex-grow: 1;
text-align: center;
--mdc-shape-small: 0;
--text-field-appearance: none;

View File

@@ -23,9 +23,6 @@ export class HaButton extends Button {
.slot-container {
overflow: var(--button-slot-container-overflow, visible);
}
:host([destructive]) {
--mdc-theme-primary: var(--error-color);
}
`,
];
}

View File

@@ -51,7 +51,7 @@ export class HaDateRangePicker extends LitElement {
public autoApply = false;
@property({ attribute: "time-picker", type: Boolean })
public timePicker = false;
public timePicker = true;
@property({ type: Boolean }) public disabled = false;

View File

@@ -79,7 +79,6 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
.disabled=${this.disabled}
@opening=${this._handleOpen}
@closing=${this._handleClose}
positioning="fixed"
>
<ha-textfield
slot="trigger"

View File

@@ -17,7 +17,6 @@ export class HaMdListItem extends MdListItem {
}
md-item {
overflow: var(--md-item-overflow, hidden);
align-items: var(--md-item-align-items, center);
}
`,
];

View File

@@ -1,6 +1,6 @@
import { mdiDeleteOutline, mdiPlus } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { haStyle } from "../resources/styles";
@@ -8,7 +8,6 @@ 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")
@@ -21,8 +20,6 @@ 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;
@@ -72,21 +69,12 @@ class HaMultiTextField extends LitElement {
</div>
`;
})}
<div class="layout horizontal">
<div class="layout horizontal center-center">
<ha-button @click=${this._addItem} .disabled=${this.disabled}>
${this.addLabel ??
(this.label
? this.hass?.localize("ui.components.multi-textfield.add_item", {
item: this.label,
})
: this.hass?.localize("ui.common.add")) ??
"Add"}
${this.addLabel ?? 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}
`;
}

View File

@@ -156,7 +156,6 @@ 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(
@@ -178,6 +177,7 @@ export class HaSelectSelector extends LitElement {
<ha-svg-icon
slot="icon"
.path=${mdiDrag}
data-handle
></ha-svg-icon>
`
: nothing}

View File

@@ -50,7 +50,6 @@ 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}
>

View File

@@ -1115,8 +1115,6 @@ 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 {
@@ -1128,6 +1126,10 @@ 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;
}
@@ -1142,6 +1144,10 @@ 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;
@@ -1325,6 +1331,11 @@ 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);
}
`,
];
}

View File

@@ -7,15 +7,13 @@ 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;
humidity_entity_id: string | null;
icon: string | null;
labels: string[];
name: string;
picture: string | null;
temperature_entity_id: string | null;
icon: string | null;
labels: string[];
aliases: string[];
}
export type AreaEntityLookup = Record<string, EntityRegistryEntry[]>;
@@ -23,14 +21,12 @@ export type AreaEntityLookup = Record<string, EntityRegistryEntry[]>;
export type AreaDeviceLookup = Record<string, DeviceRegistryEntry[]>;
export interface AreaRegistryEntryMutableParams {
aliases?: string[];
floor_id?: string | null;
humidity_entity_id?: string | null;
icon?: string | null;
labels?: string[];
name: string;
floor_id?: string | null;
picture?: string | null;
temperature_entity_id?: string | null;
icon?: string | null;
aliases?: string[];
labels?: string[];
}
export const createAreaRegistryEntry = (

View File

@@ -11,34 +11,22 @@ import type { HomeAssistant } from "../types";
import { fileDownload } from "../util/file_download";
import { domainToName } from "./integration";
import type { FrontendLocaleData } from "./translation";
import checkValidDate from "../common/datetime/check_valid_date";
export const enum BackupScheduleRecurrence {
export const enum BackupScheduleState {
NEVER = "never",
DAILY = "daily",
CUSTOM_DAYS = "custom_days",
MONDAY = "mon",
TUESDAY = "tue",
WEDNESDAY = "wed",
THURSDAY = "thu",
FRIDAY = "fri",
SATURDAY = "sat",
SUNDAY = "sun",
}
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;
@@ -53,11 +41,8 @@ export interface BackupConfig {
days?: number | null;
};
schedule: {
recurrence: BackupScheduleRecurrence;
time?: string | null;
days: BackupDay[];
state: BackupScheduleState;
};
agents: BackupAgentsConfig;
}
export interface BackupMutableConfig {
@@ -74,35 +59,20 @@ export interface BackupMutableConfig {
copies?: number | null;
days?: number | null;
};
schedule?: {
recurrence: BackupScheduleRecurrence;
time?: string | null;
days?: BackupDay[] | null;
};
agents?: BackupAgentsConfig;
}
export type BackupAgentsConfig = Record<string, BackupAgentConfig>;
export interface BackupAgentConfig {
protected: boolean;
schedule?: BackupScheduleState;
}
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;
agents: Record<string, BackupContentAgent>;
protected: boolean;
size: number;
agent_ids?: string[];
failed_agent_ids?: string[];
with_automatic_settings: boolean;
}
@@ -165,12 +135,8 @@ export const updateBackupConfig = (
config: BackupMutableConfig
) => hass.callWS({ type: "backup/config/update", ...config });
export const getBackupDownloadUrl = (
id: string,
agentId: string,
password?: string | null
) =>
`/api/backup/download/${id}?agent_id=${agentId}${password ? `&password=${password}` : ""}`;
export const getBackupDownloadUrl = (id: string, agentId: string) =>
`/api/backup/download/${id}?agent_id=${agentId}`;
export const fetchBackupInfo = (hass: HomeAssistant): Promise<BackupInfo> =>
hass.callWS({
@@ -263,19 +229,6 @@ 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";
@@ -291,18 +244,13 @@ export const isNetworkMountAgent = (agentId: string) => {
export const computeBackupAgentName = (
localize: LocalizeFunc,
agentId: string,
agents: BackupAgent[]
agentIds?: string[]
) => {
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;
}
@@ -310,15 +258,13 @@ export const computeBackupAgentName = (
const domainName = domainToName(localize, domain);
// If there are multiple agents for a domain, show the name
const showName =
agents.filter((a) => a.agent_id.split(".")[0] === domain).length > 1;
const showName = agentIds
? agentIds.filter((a) => a.split(".")[0] === domain).length > 1
: true;
return showName ? `${domainName}: ${name}` : domainName;
};
export const computeBackupSize = (backup: BackupContent) =>
Math.max(...Object.values(backup.agents).map((agent) => agent.size));
export const compareAgents = (a: string, b: string) => {
const isLocalA = isLocalAgent(a);
const isLocalB = isLocalAgent(b);
@@ -391,34 +337,9 @@ 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,
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)}`;
(locale: FrontendLocaleData, config: HassConfig) => {
const date = setMinutes(setHours(new Date(), 4), 45);
return formatTime(date, locale, config);
}
);

View File

@@ -1,167 +0,0 @@
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
);
};

View File

@@ -313,34 +313,21 @@ export const installHassioAddon = async (
export const updateHassioAddon = async (
hass: HomeAssistant,
slug: string,
backup: boolean
slug: string
): 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 },
});
return;
} else {
await hass.callApi<HassioResponse<void>>(
"POST",
`hassio/addons/${slug}/update`
);
}
await hass.callApi<HassioResponse<void>>(
"POST",
`hassio/addons/${slug}/update`,
{ backup }
);
};
export const restartHassioAddon = async (

View File

@@ -5,7 +5,6 @@ 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",

View File

@@ -2,8 +2,6 @@ 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[];

View File

@@ -6,27 +6,15 @@ export const restartCore = async (hass: HomeAssistant) => {
await hass.callService("homeassistant", "restart");
};
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;
}
export const updateCore = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/core/update",
method: "post",
timeout: null,
data: { backup },
});
return;
} else {
await hass.callApi<HassioResponse<void>>("POST", "hassio/core/update");
}
await hass.callApi<HassioResponse<void>>("POST", "hassio/core/update", {
backup,
});
};

View File

@@ -13,7 +13,6 @@ 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,
@@ -61,10 +60,6 @@ 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
@@ -74,22 +69,22 @@ export const filterUpdateEntities = (
(entity) => computeStateDomain(entity) === "update"
) as UpdateEntity[]
).sort((a, b) => {
if (a.attributes.title === HOME_ASSISTANT_CORE_TITLE) {
if (a.attributes.title === "Home Assistant Core") {
return -3;
}
if (b.attributes.title === HOME_ASSISTANT_CORE_TITLE) {
if (b.attributes.title === "Home Assistant Core") {
return 3;
}
if (a.attributes.title === HOME_ASSISTANT_OS_TITLE) {
if (a.attributes.title === "Home Assistant Operating System") {
return -2;
}
if (b.attributes.title === HOME_ASSISTANT_OS_TITLE) {
if (b.attributes.title === "Home Assistant Operating System") {
return 2;
}
if (a.attributes.title === HOME_ASSISTANT_SUPERVISOR_TITLE) {
if (a.attributes.title === "Home Assistant Supervisor") {
return -1;
}
if (b.attributes.title === HOME_ASSISTANT_SUPERVISOR_TITLE) {
if (b.attributes.title === "Home Assistant Supervisor") {
return 1;
}
return caseInsensitiveStringCompare(
@@ -206,32 +201,3 @@ 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";
};

View File

@@ -312,31 +312,32 @@ 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() {

View File

@@ -1,6 +1,7 @@
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";
@@ -116,7 +117,9 @@ class DialogBox extends LitElement {
@click=${this._confirm}
?dialogInitialFocus=${!this._params.prompt &&
!this._params.destructive}
?destructive=${this._params.destructive}
class=${classMap({
destructive: this._params.destructive || false,
})}
>
${this._params.confirmText
? this._params.confirmText
@@ -184,6 +187,9 @@ class DialogBox extends LitElement {
.secondary {
color: var(--secondary-text-color);
}
.destructive {
--mdc-theme-primary: var(--error-color);
}
ha-textfield {
width: 100%;
}

View File

@@ -2,7 +2,6 @@ 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";
@@ -11,18 +10,10 @@ import "../../../components/ha-circular-progress";
import "../../../components/ha-faded";
import "../../../components/ha-formfield";
import "../../../components/ha-markdown";
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 "../../../components/ha-settings-row";
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,
@@ -42,103 +33,6 @@ 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 ||
@@ -153,8 +47,6 @@ 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">
@@ -241,27 +133,6 @@ 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
@@ -315,14 +186,6 @@ 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() {
@@ -342,28 +205,11 @@ 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
@@ -443,18 +289,14 @@ class MoreInfoUpdate extends LitElement {
z-index: 10;
}
ha-md-list {
ha-settings-row {
width: 100%;
padding: 0 24px;
box-sizing: border-box;
margin-bottom: -16px;
margin-top: -4px;
}
ha-md-list-item {
--md-list-item-leading-space: 24px;
--md-list-item-trailing-space: 24px;
}
.actions {
width: 100%;
display: flex;

View File

@@ -95,10 +95,7 @@ export class HassRouterPage extends ReactiveElement {
const defaultPage = routerOptions.defaultPage;
if (route && route.path === "" && defaultPage !== undefined) {
const queryParams = window.location.search;
navigate(`${route.prefix}/${defaultPage}${queryParams}`, {
replace: true,
});
navigate(`${route.prefix}/${defaultPage}`, { replace: true });
}
let newPage = route

View File

@@ -5,7 +5,7 @@ import {
mdiArrowDown,
mdiArrowUp,
mdiClose,
mdiTableCog,
mdiCog,
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=${mdiTableCog}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiCog}></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}
.clickAction=${this._selectAll}
@click=${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}
.clickAction=${this._selectNone}
@click=${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}
.clickAction=${this._disableSelectMode}
@click=${this._disableSelectMode}
>
<div slot="headline">
${localize(
@@ -500,7 +500,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
? html`
<ha-md-menu-item
.value=${id}
.clickAction=${this._handleGroupBy}
@click=${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}
.clickAction=${this._handleGroupBy}
@click=${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
.clickAction=${this._collapseAllGroups}
@click=${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
.clickAction=${this._expandAllGroups}
@click=${this._expandAllGroups}
.disabled=${this._groupColumn === undefined}
>
<ha-svg-icon
@@ -546,7 +546,6 @@ 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 })}
@@ -624,8 +623,6 @@ 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";
@@ -642,9 +639,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
});
}
private _handleGroupBy = (item) => {
this._setGroupColumn(item.value);
};
private _handleGroupBy(ev) {
this._setGroupColumn(ev.currentTarget.value);
}
private _setGroupColumn(columnId: string) {
this._groupColumn = columnId;
@@ -668,30 +665,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) {

View File

@@ -1,3 +1,4 @@
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";
@@ -7,7 +8,6 @@ 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>
<ha-button @click=${this._dismiss} slot="secondaryAction">
<mwc-button @click=${this._dismiss} slot="secondaryAction">
${this.hass.localize("ui.dialogs.generic.cancel")}
</ha-button>
<ha-button
</mwc-button>
<mwc-button
slot="primaryAction"
@click=${this._confirm}
dialogInitialFocus
destructive
class="destructive"
>
${this._params.confirmText}
</ha-button>
</mwc-button>
${this._params.confirmFutureText
? html`
<ha-button
<mwc-button
@click=${this._confirmFuture}
class="destructive"
slot="primaryAction"
destructive
>
${this._params.confirmFutureText}
</ha-button>
</mwc-button>
`
: ""}
</ha-dialog>
@@ -120,6 +120,9 @@ 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;

View File

@@ -3,7 +3,6 @@ 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";
@@ -13,8 +12,6 @@ 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";
@@ -22,10 +19,6 @@ 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,
@@ -34,10 +27,6 @@ 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;
@@ -53,10 +42,6 @@ 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;
@@ -68,26 +53,14 @@ class DialogAreaDetail extends LitElement {
): Promise<void> {
this._params = params;
this._error = undefined;
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;
}
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;
await this.updateComplete;
}
@@ -103,7 +76,6 @@ class DialogAreaDetail extends LitElement {
}
const entry = this._params.entry;
const nameInvalid = !this._isNameValid();
const isNew = !entry;
return html`
<ha-dialog
open
@@ -189,40 +161,6 @@ 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}>
@@ -245,22 +183,6 @@ 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;
@@ -286,16 +208,6 @@ 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;
@@ -307,8 +219,6 @@ 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);
@@ -325,14 +235,17 @@ class DialogAreaDetail extends LitElement {
}
}
private _aliasesChanged(ev: CustomEvent): void {
this._aliases = ev.detail.value;
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-aliases-editor,
ha-entity-picker,
ha-floor-picker,
ha-textfield,
ha-icon-picker,
ha-floor-picker,
ha-labels-picker,
ha-picture-upload {
display: block;

View File

@@ -48,7 +48,7 @@ export class HaDelayAction extends LitElement implements ActionElement {
)}
.disabled=${this.disabled}
.data=${this._timeData}
enable-millisecond
enableMillisecond
required
@value-changed=${this._valueChanged}
></ha-duration-input>`;

View File

@@ -38,7 +38,7 @@ export class HaWaitForTriggerAction
)}
.data=${timeData}
.disabled=${this.disabled}
enable-millisecond
enableMillisecond
@value-changed=${this._timeoutChanged}
></ha-duration-input>
<ha-formfield

View File

@@ -20,12 +20,13 @@ import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type {
AutomationRenameDialogParams,
EntityRegistryUpdate,
SaveDialogParams,
} from "./show-dialog-automation-save";
ScriptRenameDialogParams,
} from "./show-dialog-automation-rename";
@customElement("ha-dialog-automation-save")
class DialogAutomationSave extends LitElement implements HassDialog {
@customElement("ha-dialog-automation-rename")
class DialogAutomationRename extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@@ -36,7 +37,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
@state() private _entryUpdates!: EntityRegistryUpdate;
private _params!: SaveDialogParams;
private _params!: AutomationRenameDialogParams | ScriptRenameDialogParams;
private _newName?: string;
@@ -44,7 +45,9 @@ class DialogAutomationSave extends LitElement implements HassDialog {
private _newDescription?: string;
public showDialog(params: SaveDialogParams): void {
public showDialog(
params: AutomationRenameDialogParams | ScriptRenameDialogParams
): void {
this._opened = true;
this._params = params;
this._newIcon = "icon" in params.config ? params.config.icon : undefined;
@@ -92,153 +95,20 @@ class DialogAutomationSave 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=${title}
.heading=${this.hass.localize(
this._params.config.alias
? "ui.panel.config.automation.editor.rename"
: "ui.panel.config.automation.editor.save"
)}
>
<ha-dialog-header slot="heading">
<ha-icon-button
@@ -247,7 +117,13 @@ class DialogAutomationSave extends LitElement implements HassDialog {
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title">${this._params.title || title}</span>
<span slot="title"
>${this.hass.localize(
this._params.config.alias
? "ui.panel.config.automation.editor.rename"
: "ui.panel.config.automation.editor.save"
)}</span
>
</ha-dialog-header>
${this._error
? html`<ha-alert alert-type="error"
@@ -256,10 +132,114 @@ class DialogAutomationSave extends LitElement implements HassDialog {
)}</ha-alert
>`
: ""}
${this._params.description
? html`<p>${this._params.description}</p>`
<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._renderInputs()} ${this._renderDiscard()}
${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>
<div slot="primaryAction">
<mwc-button @click=${this.closeDialog}>
@@ -267,7 +247,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
</mwc-button>
<mwc-button @click=${this._save}>
${this.hass.localize(
this._params.config.alias && !this._params.onDiscard
this._params.config.alias
? "ui.panel.config.automation.editor.rename"
: "ui.panel.config.automation.editor.save"
)}
@@ -306,19 +286,14 @@ class DialogAutomationSave extends LitElement implements HassDialog {
}
}
private _handleDiscard() {
this._params.onDiscard?.();
this.closeDialog();
}
private async _save(): Promise<void> {
private _save(): void {
if (!this._newName) {
this._error = "Name is required";
return;
}
if (this._params.domain === "script") {
await this._params.updateConfig(
this._params.updateConfig(
{
...this._params.config,
alias: this._newName,
@@ -328,7 +303,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
this._entryUpdates
);
} else {
await this._params.updateConfig(
this._params.updateConfig(
{
...this._params.config,
alias: this._newName,
@@ -376,9 +351,6 @@ class DialogAutomationSave extends LitElement implements HassDialog {
display: block;
margin-bottom: 16px;
}
.destructive {
--mdc-theme-primary: var(--error-color);
}
`,
];
}
@@ -386,6 +358,6 @@ class DialogAutomationSave extends LitElement implements HassDialog {
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-automation-save": DialogAutomationSave;
"ha-dialog-automation-rename": DialogAutomationRename;
}
}

View File

@@ -3,18 +3,13 @@ import type { AutomationConfig } from "../../../../data/automation";
import type { ScriptConfig } from "../../../../data/script";
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
export const loadAutomationSaveDialog = () =>
import("./dialog-automation-save");
export const loadAutomationRenameDialog = () =>
import("./dialog-automation-rename");
interface BaseRenameDialogParams {
entityRegistryUpdate?: EntityRegistryUpdate;
entityRegistryEntry?: EntityRegistryEntry;
onClose: () => void;
onDiscard?: () => void;
saveText?: string;
description?: string;
title?: string;
hideInputs?: boolean;
}
export interface EntityRegistryUpdate {
@@ -23,35 +18,31 @@ export interface EntityRegistryUpdate {
category: string;
}
export interface AutomationSaveDialogParams extends BaseRenameDialogParams {
export interface AutomationRenameDialogParams extends BaseRenameDialogParams {
config: AutomationConfig;
domain: "automation";
updateConfig: (
config: AutomationConfig,
entityRegistryUpdate: EntityRegistryUpdate
) => Promise<void>;
) => void;
}
export interface ScriptSaveDialogParams extends BaseRenameDialogParams {
export interface ScriptRenameDialogParams extends BaseRenameDialogParams {
config: ScriptConfig;
domain: "script";
updateConfig: (
config: ScriptConfig,
entityRegistryUpdate: EntityRegistryUpdate
) => Promise<void>;
) => void;
}
export type SaveDialogParams =
| AutomationSaveDialogParams
| ScriptSaveDialogParams;
export const showAutomationSaveDialog = (
export const showAutomationRenameDialog = (
element: HTMLElement,
dialogParams: SaveDialogParams
dialogParams: AutomationRenameDialogParams | ScriptRenameDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-automation-save",
dialogImport: loadAutomationSaveDialog,
dialogTag: "ha-dialog-automation-rename",
dialogImport: loadAutomationRenameDialog,
dialogParams,
});
};

View File

@@ -19,7 +19,7 @@ import {
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { LitElement, css, html, 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,
showAutomationSaveDialog,
} from "./automation-save-dialog/show-dialog-automation-save";
showAutomationRenameDialog,
} from "./automation-rename-dialog/show-dialog-automation-rename";
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._handleSaveAutomation}
@click=${this._saveAutomation}
>
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
</ha-fab>
@@ -743,48 +743,20 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
}
private async _confirmUnsavedChanged(): Promise<boolean> {
if (!this._dirty) {
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"
if (this._dirty) {
return showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.automation.editor.unsaved_confirm_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"
text: this.hass!.localize(
"ui.panel.config.automation.editor.unsaved_confirm_text"
),
hideInputs: this.automationId !== null,
confirmText: this.hass!.localize("ui.common.leave"),
dismissText: this.hass!.localize("ui.common.stay"),
destructive: true,
});
});
}
return true;
}
private _backTapped = async () => {
@@ -906,10 +878,10 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
private async _promptAutomationAlias(): Promise<boolean> {
return new Promise((resolve) => {
showAutomationSaveDialog(this, {
showAutomationRenameDialog(this, {
config: this._config!,
domain: "automation",
updateConfig: async (config, entityRegistryUpdate) => {
updateConfig: (config, entityRegistryUpdate) => {
this._config = config;
this._entityRegistryUpdate = entityRegistryUpdate;
this._dirty = true;
@@ -938,7 +910,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
});
}
private async _handleSaveAutomation(): Promise<void> {
private async _saveAutomation(): Promise<void> {
if (this._yamlErrors) {
showToast(this, {
message: this._yamlErrors,
@@ -954,13 +926,6 @@ 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;
@@ -1025,6 +990,10 @@ 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, {
@@ -1047,7 +1016,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
protected supportedShortcuts(): SupportedShortcuts {
return {
s: () => this._handleSaveAutomation(),
s: () => this._saveAutomation(),
};
}

View File

@@ -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 { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time";
import { formatShortDateTime } 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,11 +324,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
const dayDifference = differenceInDays(now, date);
return html`
${dayDifference > 3
? formatShortDateTimeWithConditionalYear(
date,
this.hass.locale,
this.hass.config
)
? formatShortDateTime(date, locale, this.hass.config)
: relativeTime(date, locale)}
`;
},
@@ -403,7 +399,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
(category) =>
html`<ha-md-menu-item
.value=${category.category_id}
.clickAction=${this._handleBulkCategory}
@click=${this._handleBulkCategory}
>
${category.icon
? html`<ha-icon slot="start" .icon=${category.icon}></ha-icon>`
@@ -411,7 +407,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
<div slot="headline">${category.name}</div>
</ha-md-menu-item>`
)}
<ha-md-menu-item .value=${null} .clickAction=${this._handleBulkCategory}>
<ha-md-menu-item .value=${null} @click=${this._handleBulkCategory}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.no_category"
@@ -419,7 +415,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 .clickAction=${this._bulkCreateCategory}>
<ha-md-menu-item @click=${this._bulkCreateCategory}>
<div slot="headline">
${this.hass.localize("ui.panel.config.category.editor.add")}
</div>
@@ -456,7 +452,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
</ha-md-menu-item>`;
})}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item .clickAction=${this._bulkCreateLabel}>
<ha-md-menu-item @click=${this._bulkCreateLabel}>
<div slot="headline">
${this.hass.localize("ui.panel.config.labels.add_label")}
</div></ha-md-menu-item
@@ -466,7 +462,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
(area) =>
html`<ha-md-menu-item
.value=${area.area_id}
.clickAction=${this._handleBulkArea}
@click=${this._handleBulkArea}
>
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
@@ -477,7 +473,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
<div slot="headline">${area.name}</div>
</ha-md-menu-item>`
)}
<ha-md-menu-item .value=${null} .clickAction=${this._handleBulkArea}>
<ha-md-menu-item .value=${null} @click=${this._handleBulkArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.no_area"
@@ -485,7 +481,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 .clickAction=${this._bulkCreateArea}>
<ha-md-menu-item @click=${this._bulkCreateArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.add_area"
@@ -542,7 +538,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}
@@ -760,7 +756,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
</ha-sub-menu>`
: nothing
}
<ha-md-menu-item .clickAction=${this._handleBulkEnable}>
<ha-md-menu-item @click=${this._handleBulkEnable}>
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
@@ -768,7 +764,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._handleBulkDisable}>
<ha-md-menu-item @click=${this._handleBulkDisable}>
<ha-svg-icon
slot="start"
.path=${mdiToggleSwitchOffOutline}
@@ -1243,10 +1239,10 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
}
}
private _handleBulkCategory = async (item) => {
const category = item.value;
private async _handleBulkCategory(ev) {
const category = ev.currentTarget.value;
this._bulkAddCategory(category);
};
}
private async _bulkAddCategory(category: string) {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
@@ -1309,10 +1305,10 @@ ${rejected
}
}
private _handleBulkArea = (item) => {
const area = item.value;
private async _handleBulkArea(ev) {
const area = ev.currentTarget.value;
this._bulkAddArea(area);
};
}
private async _bulkAddArea(area: string) {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
@@ -1339,7 +1335,7 @@ ${rejected
}
}
private _bulkCreateArea = async () => {
private async _bulkCreateArea() {
showAreaRegistryDetailDialog(this, {
createEntry: async (values) => {
const area = await createAreaRegistryEntry(this.hass, values);
@@ -1347,9 +1343,9 @@ ${rejected
return area;
},
});
};
}
private _handleBulkEnable = async () => {
private async _handleBulkEnable() {
const promises: Promise<ServiceCallResponse>[] = [];
this._selected.forEach((entityId) => {
promises.push(turnOnOffEntity(this.hass, entityId, true));
@@ -1368,9 +1364,9 @@ ${rejected
>`,
});
}
};
}
private _handleBulkDisable = async () => {
private async _handleBulkDisable() {
const promises: Promise<ServiceCallResponse>[] = [];
this._selected.forEach((entityId) => {
promises.push(turnOnOffEntity(this.hass, entityId, false));
@@ -1389,9 +1385,9 @@ ${rejected
>`,
});
}
};
}
private _bulkCreateCategory = async () => {
private async _bulkCreateCategory() {
showCategoryRegistryDetailDialog(this, {
scope: "automation",
createEntry: async (values) => {
@@ -1404,9 +1400,9 @@ ${rejected
return category;
},
});
};
}
private _bulkCreateLabel = () => {
private _bulkCreateLabel() {
showLabelDetailDialog(this, {
createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values);
@@ -1414,14 +1410,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) {

View File

@@ -9,11 +9,9 @@ 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 {
@@ -35,7 +33,8 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
private _schema = memoizeOne(
(
localize: LocalizeFunc,
inputMode: typeof MODE_TIME | typeof MODE_ENTITY
inputMode: typeof MODE_TIME | typeof MODE_ENTITY,
showOffset: boolean
) =>
[
{
@@ -66,13 +65,16 @@ 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
);
@@ -105,7 +107,9 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
const entity =
typeof at === "object"
? at.entity_id
: at && VALID_DOMAINS.includes(computeDomain(at))
: at?.startsWith("input_datetime.") ||
at?.startsWith("time.") ||
at?.startsWith("sensor.")
? at
: undefined;
const time = entity ? undefined : (at as string | undefined);
@@ -128,7 +132,9 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
}
const data = this._data(this._inputMode, at);
const schema = this._schema(this.hass.localize, data.mode);
const showOffset =
data.mode === MODE_ENTITY && data.entity?.startsWith("sensor.");
const schema = this._schema(this.hass.localize, data.mode, !!showOffset);
return html`
<ha-form
@@ -151,6 +157,9 @@ 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: {

View File

@@ -33,7 +33,6 @@ 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>
`;
@@ -51,13 +50,6 @@ 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 {

View File

@@ -1,28 +1,24 @@
import { mdiCog, mdiHarddisk, mdiNas } from "@mdi/js";
import { mdiHarddisk, mdiNas } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { 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 = [];
@@ -32,21 +28,22 @@ class HaBackupConfigAgents extends LitElement {
@property({ attribute: false }) public cloudStatus!: CloudStatus;
@property({ attribute: false }) public agents: BackupAgent[] = [];
@property({ attribute: false }) public agentsConfig?: BackupAgentsConfig;
@property({ type: Boolean, attribute: "show-settings" }) public showSettings =
false;
@state() private _agentIds: string[] = [];
@state() private value?: string[];
private _availableAgents = memoizeOne(
(agents: BackupAgent[], cloudStatus: CloudStatus) =>
agents.filter(
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
)
);
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;
@@ -63,21 +60,6 @@ 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"
@@ -87,25 +69,22 @@ class HaBackupConfigAgents extends LitElement {
}
protected render() {
const agents = this._availableAgents(this.agents, this.cloudStatus);
return html`
${agents.length > 0
${this._agentIds.length > 0
? html`
<ha-md-list>
${agents.map((agent) => {
const agentId = agent.agent_id;
${this._agentIds.map((agentId) => {
const domain = computeDomain(agentId);
const name = computeBackupAgentName(
this.hass.localize,
agentId,
this.agents
this._agentIds
);
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)
@@ -138,16 +117,6 @@ class HaBackupConfigAgents extends LitElement {
${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}
@@ -161,19 +130,12 @@ class HaBackupConfigAgents extends LitElement {
})}
</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 _agentToggled(ev) {
ev.stopPropagation();
const value = ev.currentTarget.checked;
@@ -185,14 +147,9 @@ class HaBackupConfigAgents extends LitElement {
this.value = this._value.filter((agent) => agent !== agentId);
}
const availableAgents = this._availableAgents(
this.agents,
this.cloudStatus
);
// Ensure we don't have duplicates, agents exist in the list and cloud is logged in
this.value = [...new Set(this.value)]
.filter((id) => availableAgents.some((agent) => agent.agent_id === id))
.filter((agent) => this._agentIds.some((id) => id === agent))
.filter(
(id) =>
id !== CLOUD_AGENT ||
@@ -221,25 +178,6 @@ 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);
}
`;
}

View File

@@ -12,22 +12,12 @@ 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, BackupDay } from "../../../../../data/backup";
import type { BackupConfig } from "../../../../../data/backup";
import {
BACKUP_DAYS,
BackupScheduleRecurrence,
DEFAULT_OPTIMIZED_BACKUP_END_TIME,
DEFAULT_OPTIMIZED_BACKUP_START_TIME,
sortWeekdays,
BackupScheduleState,
getFormattedBackupTime,
} 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">;
@@ -40,11 +30,6 @@ enum RetentionPreset {
CUSTOM = "custom",
}
enum BackupScheduleTime {
DEFAULT = "default",
CUSTOM = "custom",
}
interface RetentionData {
type: "copies" | "days";
value: number;
@@ -59,10 +44,15 @@ const RETENTION_PRESETS: Record<
};
const SCHEDULE_OPTIONS = [
BackupScheduleRecurrence.NEVER,
BackupScheduleRecurrence.DAILY,
BackupScheduleRecurrence.CUSTOM_DAYS,
] as const satisfies BackupScheduleRecurrence[];
BackupScheduleState.DAILY,
BackupScheduleState.MONDAY,
BackupScheduleState.TUESDAY,
BackupScheduleState.WEDNESDAY,
BackupScheduleState.THURSDAY,
BackupScheduleState.FRIDAY,
BackupScheduleState.SATURDAY,
BackupScheduleState.SUNDAY,
] as const satisfies BackupScheduleState[];
const RETENTION_PRESETS_OPTIONS = [
RetentionPreset.COPIES_3,
@@ -70,11 +60,6 @@ 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 => {
@@ -87,10 +72,8 @@ const computeRetentionPreset = (
};
interface FormData {
recurrence: BackupScheduleRecurrence;
time_option: BackupScheduleTime;
time?: string | null;
days: BackupDay[];
enabled: boolean;
schedule: BackupScheduleState;
retention: {
type: "copies" | "days";
value: number;
@@ -98,9 +81,8 @@ interface FormData {
}
const INITIAL_FORM_DATA: FormData = {
recurrence: BackupScheduleRecurrence.NEVER,
time_option: BackupScheduleTime.DEFAULT,
days: [],
enabled: false,
schedule: BackupScheduleState.NEVER,
retention: {
type: "copies",
value: 3,
@@ -132,15 +114,8 @@ class HaBackupConfigSchedule extends LitElement {
const config = value;
return {
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
: [],
enabled: config.schedule.state !== BackupScheduleState.NEVER,
schedule: config.schedule.state,
retention: {
type: config.retention.days != null ? "days" : "copies",
value: config.retention.days ?? config.retention.copies ?? 3,
@@ -150,14 +125,8 @@ class HaBackupConfigSchedule extends LitElement {
private _setData(data: FormData) {
this.value = {
...this.value,
schedule: {
recurrence: data.recurrence,
time: data.time_option === BackupScheduleTime.CUSTOM ? data.time : null,
days:
data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS
? data.days
: [],
state: data.enabled ? data.schedule : BackupScheduleState.NEVER,
},
retention:
data.retention.type === "days"
@@ -171,113 +140,49 @@ 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.schedule"
)}</span
>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.schedule.schedule_description"
"ui.panel.config.backup.schedule.use_automatic_backups"
)}
</span>
<ha-md-select
<ha-switch
slot="end"
@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>
@change=${this._enabledChanged}
.checked=${data.enabled}
></ha-switch>
</ha-md-list-item>
${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)
${data.enabled
? html`
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.time"
)}</span
>
"ui.panel.config.backup.schedule.schedule"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"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
),
}
"ui.panel.config.backup.schedule.schedule_description"
)}
</span>
<ha-md-select
slot="end"
@change=${this._scheduleTimeChanged}
.value=${data.time_option}
@change=${this._scheduleChanged}
.value=${data.schedule}
>
${SCHEDULE_TIME_OPTIONS.map(
${SCHEDULE_OPTIONS.map(
(option) => html`
<ha-md-select-option .value=${option}>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.time_options.${option}`
`ui.panel.config.backup.schedule.schedule_options.${option}`,
{ time }
)}
</div>
</ha-md-select-option>
@@ -285,197 +190,100 @@ class HaBackupConfigSchedule extends LitElement {
)}
</ha-md-select>
</ha-md-list-item>
${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>
<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"
@value-changed=${this._timeChanged}
.value=${data.time ?? undefined}
.locale=${this.hass.locale}
>
</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"
`ui.panel.config.backup.schedule.retention`
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.schedule.retention_description`
)}
</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"
@change=${this._retentionPresetChanged}
.value=${this._retentionPreset}
>
<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>
${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></ha-expansion-panel
> `
</ha-md-list-item>
${this._retentionPreset === RetentionPreset.CUSTOM
? html`
<ha-md-list-item>
<ha-md-textfield
slot="end"
@change=${this._retentionValueChanged}
.value=${data.retention.value}
id="value"
type="number"
.min=${MIN_VALUE}
.max=${MAX_VALUE}
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>
`
: nothing}
`
: 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#example-backing-up-every-night-at-300-am"
)}
target="_blank"
rel="noopener noreferrer"
>backup.create</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,
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,
schedule: target.value as BackupScheduleState,
});
fireEvent(this, "value-changed", { value: this.value });
}
private _retentionPresetChanged(ev) {
@@ -496,6 +304,8 @@ class HaBackupConfigSchedule extends LitElement {
retention: RETENTION_PRESETS[value],
});
}
fireEvent(this, "value-changed", { value: this.value });
}
private _retentionValueChanged(ev) {
@@ -511,6 +321,8 @@ class HaBackupConfigSchedule extends LitElement {
value: clamped,
},
});
fireEvent(this, "value-changed", { value: this.value });
}
private _retentionTypeChanged(ev) {
@@ -526,6 +338,8 @@ class HaBackupConfigSchedule extends LitElement {
type: value,
},
});
fireEvent(this, "value-changed", { value: this.value });
}
static styles = css`
@@ -537,13 +351,11 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-list-item {
--md-item-overflow: visible;
}
ha-md-select,
ha-time-input {
ha-md-select {
min-width: 210px;
}
@media all and (max-width: 450px) {
ha-md-select,
ha-time-input {
ha-md-select {
min-width: 160px;
}
}
@@ -553,21 +365,6 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-select#type {
min-width: 100px;
}
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);
}
`;
}

View File

@@ -7,7 +7,6 @@ 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,
@@ -25,7 +24,7 @@ class HaBackupAgentsPicker extends LitElement {
public disabled = false;
@property({ attribute: false })
public agents!: BackupAgent[];
public agentIds!: string[];
@property({ attribute: false })
public disabledAgentIds?: string[];
@@ -36,30 +35,30 @@ class HaBackupAgentsPicker extends LitElement {
render() {
return html`
<div class="agents">
${this.agents.map((agent) => this._renderAgent(agent))}
${this.agentIds.map((agent) => this._renderAgent(agent))}
</div>
`;
}
private _renderAgent(agent: BackupAgent) {
const domain = computeDomain(agent.agent_id);
private _renderAgent(agentId: string) {
const domain = computeDomain(agentId);
const name = computeBackupAgentName(
this.hass.localize,
agent.agent_id,
this.agents
agentId,
this.agentIds
);
const disabled =
this.disabled || this.disabledAgentIds?.includes(agent.agent_id) || false;
this.disabled || this.disabledAgentIds?.includes(agentId) || false;
return html`
<ha-formfield>
<span class="label ${classMap({ disabled })}" slot="label">
${isLocalAgent(agent.agent_id)
${isLocalAgent(agentId)
? html`
<ha-svg-icon .path=${mdiHarddisk} slot="start"> </ha-svg-icon>
`
: isNetworkMountAgent(agent.agent_id)
: isNetworkMountAgent(agentId)
? html` <ha-svg-icon .path=${mdiNas} slot="start"></ha-svg-icon> `
: html`
<img
@@ -78,8 +77,8 @@ class HaBackupAgentsPicker extends LitElement {
${name}
</span>
<ha-checkbox
.checked=${this.value.includes(agent.agent_id)}
.value=${agent.agent_id}
.checked=${this.value.includes(agentId)}
.value=${agentId}
.disabled=${disabled}
@change=${this._checkboxChanged}
></ha-checkbox>

View File

@@ -4,7 +4,6 @@ import {
mdiFolder,
mdiPlayBoxMultiple,
mdiPuzzle,
mdiShieldCheck,
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -37,7 +36,6 @@ const ITEM_ICONS = {
database: mdiChartBox,
media: mdiPlayBoxMultiple,
share: mdiFolder,
ssl: mdiShieldCheck,
};
interface SelectedItems {
@@ -106,8 +104,6 @@ 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"
@@ -171,14 +167,15 @@ export class HaBackupDataPicker extends LitElement {
})
);
private _homeassistantChanged(ev: Event) {
private _itemChanged(ev: Event) {
const itemValues = this._parseValue(this.value);
const checkbox = ev.currentTarget as HaCheckbox;
const section = (checkbox as any).section;
if (checkbox.checked) {
itemValues.homeassistant.push(checkbox.id);
itemValues[section].push(checkbox.id);
} else {
itemValues.homeassistant = itemValues.homeassistant.filter(
itemValues[section] = itemValues[section].filter(
(id) => id !== checkbox.id
);
}
@@ -265,7 +262,8 @@ export class HaBackupDataPicker extends LitElement {
.checked=${selectedItems.homeassistant.includes(
item.id
)}
@change=${this._homeassistantChanged}
.section=${"homeassistant"}
@change=${this._itemChanged}
></ha-checkbox>
</ha-formfield>
`
@@ -281,7 +279,7 @@ export class HaBackupDataPicker extends LitElement {
<ha-backup-formfield-label
slot="label"
.label=${this.hass.localize(
"ui.panel.config.backup.data_picker.addons"
"ui.panel.config.backup.data_picker.local_addons"
)}
.iconPath=${mdiPuzzle}
>

View File

@@ -8,10 +8,7 @@ import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import {
computeBackupSize,
type BackupContent,
} from "../../../../../data/backup";
import type { BackupContent } from "../../../../../data/backup";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { bytesToString } from "../../../../../util/bytes-to-string";
@@ -25,7 +22,7 @@ const computeBackupStats = (backups: BackupContent[]): BackupStats =>
backups.reduce(
(stats, backup) => {
stats.count++;
stats.size += computeBackupSize(backup);
stats.size += backup.size;
return stats;
},
{ count: 0, size: 0 }

View File

@@ -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 { BackupAgent, BackupConfig } from "../../../../../data/backup";
import type { BackupConfig } from "../../../../../data/backup";
import {
BackupScheduleRecurrence,
BackupScheduleState,
computeBackupAgentName,
getFormattedBackupTime,
isLocalAgent,
@@ -25,95 +25,30 @@ 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 { recurrence } = config.schedule;
const { state: schedule } = config.schedule;
if (recurrence === BackupScheduleRecurrence.NEVER) {
if (schedule === BackupScheduleState.NEVER) {
return this.hass.localize(
"ui.panel.config.backup.overview.settings.schedule_never"
);
}
const time: string | undefined | null =
this.config.schedule.time &&
getFormattedBackupTime(
this.hass.locale,
this.hass.config,
this.config.schedule.time
);
const time = getFormattedBackupTime(this.hass.locale, this.hass.config);
let scheduleText = this.hass.localize(
"ui.panel.config.backup.overview.settings.schedule_never"
const scheduleText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_${schedule}`,
{ time }
);
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`
`ui.panel.config.backup.overview.settings.schedule_copies_all`,
{ time }
);
if (copies) {
copiesText = this.hass.localize(
@@ -162,7 +97,7 @@ class HaBackupBackupsSummary extends LitElement {
const name = computeBackupAgentName(
this.hass.localize,
offsiteLocations[0],
this.agents
offsiteLocations
);
return this.hass.localize(
"ui.panel.config.backup.overview.settings.locations_one",

View File

@@ -1,7 +1,7 @@
import { mdiBackupRestore, mdiCalendar, mdiInformation } from "@mdi/js";
import { addHours, differenceInDays, isToday, isTomorrow } from "date-fns";
import { mdiBackupRestore, mdiCalendar } from "@mdi/js";
import { addHours, differenceInDays } from "date-fns";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
@@ -10,20 +10,14 @@ 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 {
BackupScheduleRecurrence,
BackupScheduleState,
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;
@@ -82,6 +76,16 @@ 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);
@@ -90,49 +94,6 @@ 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);
@@ -161,33 +122,25 @@ class HaBackupOverviewBackups extends LitElement {
)}
</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-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>
</ha-md-list>
</ha-backup-summary-card>
`;
@@ -211,11 +164,10 @@ class HaBackupOverviewBackups extends LitElement {
)}
</span>
</ha-md-list-item>
${this._renderNextBackupDescription(
nextBackupDescription,
lastCompletedDate,
showAdditionalBackupDescription
)}
<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>
`;
@@ -251,29 +203,25 @@ class HaBackupOverviewBackups extends LitElement {
)}
</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-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>
</ha-md-list>
</ha-backup-summary-card>
`;
@@ -288,7 +236,7 @@ class HaBackupOverviewBackups extends LitElement {
now,
true
),
count: Object.keys(lastBackup.agents).length,
count: lastBackup.agent_ids?.length ?? 0,
}
);
@@ -300,71 +248,53 @@ class HaBackupOverviewBackups extends LitElement {
const isOverdue =
(numberOfDays >= 1 &&
this.config.schedule.recurrence === BackupScheduleRecurrence.DAILY) ||
this.config.schedule.state === BackupScheduleState.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.${isOverdue ? "backup_too_old_heading" : "backup_success_heading"}`,
{ count: numberOfDays }
"ui.panel.config.backup.overview.summary.backup_success_heading"
)}
.status=${isOverdue ? "warning" : "success"}
status="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>
${this._renderNextBackupDescription(
nextBackupDescription,
lastCompletedDate,
showAdditionalBackupDescription
)}
<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>
`;
}
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,

View File

@@ -21,7 +21,7 @@ import type {
BackupMutableConfig,
} from "../../../../data/backup";
import {
BackupScheduleRecurrence,
BackupScheduleState,
CLOUD_AGENT,
CORE_LOCAL_AGENT,
downloadEmergencyKit,
@@ -68,15 +68,10 @@ const RECOMMENDED_CONFIG: BackupConfig = {
days: null,
},
schedule: {
recurrence: BackupScheduleRecurrence.DAILY,
time: null,
days: [],
state: BackupScheduleState.DAILY,
},
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")
@@ -150,7 +145,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,
schedule: this._config.schedule.state,
retention: this._config.retention,
};

View File

@@ -18,7 +18,6 @@ import "../../../../components/ha-md-select";
import "../../../../components/ha-md-select-option";
import "../../../../components/ha-textfield";
import type {
BackupAgent,
BackupConfig,
GenerateBackupParams,
} from "../../../../data/backup";
@@ -65,7 +64,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
@state() private _step?: "data" | "sync";
@state() private _agents: BackupAgent[] = [];
@state() private _agentIds: string[] = [];
@state() private _backupConfig?: BackupConfig;
@@ -90,7 +89,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
}
this._step = undefined;
this._formData = undefined;
this._agents = [];
this._agentIds = [];
this._backupConfig = undefined;
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -98,14 +97,15 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
private async _fetchAgents() {
const { agents } = await fetchBackupAgentsInfo(this.hass);
this._agents = agents
this._agentIds = agents
.map((agent) => agent.agent_id)
.filter(
(agent) =>
agent.agent_id !== CLOUD_AGENT ||
(id) =>
id !== CLOUD_AGENT ||
(this._params?.cloudStatus?.logged_in &&
this._params?.cloudStatus?.active_subscription)
)
.sort((a, b) => compareAgents(a.agent_id, b.agent_id));
.sort(compareAgents);
}
private async _fetchBackupConfig() {
@@ -134,10 +134,6 @@ 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);
@@ -148,7 +144,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
// Remove disallowed agents from the list
const agentsIds =
this._formData.agents_mode === "all"
? this._allAgentIds
? this._agentIds
: this._formData.agent_ids;
const filteredAgents = agentsIds.filter(
@@ -313,7 +309,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._allAgentIds.length }
{ count: this._agentIds.length }
)}
</div>
</ha-md-select-option>
@@ -354,7 +350,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
.hass=${this.hass}
.value=${this._formData.agent_ids}
@value-changed=${this._agentsChanged}
.agents=${this._agents}
.agentIds=${this._agentIds}
.disabledAgentIds=${disabledAgentIds}
></ha-backup-agents-picker>
</ha-expansion-panel>
@@ -389,7 +385,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
if (!this._formData) {
return [];
}
const allAgents = this._allAgentIds;
const allAgents = this._agentIds;
return !this._formData.data.include_homeassistant
? DISALLOWED_AGENTS_NO_HA.filter((agentId) => allAgents.includes(agentId))
: [];
@@ -407,7 +403,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
const params: GenerateBackupParams = {
name,
password,
agent_ids: agents_mode === "all" ? this._allAgentIds : agent_ids,
agent_ids: agents_mode === "all" ? this._agentIds : agent_ids,
// We always include homeassistant if we include database
include_homeassistant:
data.include_homeassistant || data.include_database,

View File

@@ -1,35 +1,33 @@
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";
@@ -78,12 +76,7 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
this._error = undefined;
this._state = undefined;
this._stage = undefined;
const agentIds = Object.keys(this._params.backup.agents);
const preferedAgent = getPreferredAgentForDownload(agentIds);
const isProtected = this._params.backup.agents[preferedAgent]?.protected;
if (isProtected) {
if (this._params.backup.protected) {
this._backupEncryptionKey = await this._fetchEncryptionKey();
if (!this._backupEncryptionKey) {
this._step = STEPS[1];
@@ -229,7 +222,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} destructive>
<ha-button @click=${this._restoreBackup} class="destructive">
${this.hass.localize(
"ui.panel.config.backup.dialogs.restore.actions.restore"
)}
@@ -327,26 +320,22 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
return;
}
const agentIds = Object.keys(this._params.backup.agents);
const preferedAgent = getPreferredAgentForDownload(agentIds);
const preferedAgent = getPreferredAgentForDownload(
this._params.backup.agent_ids!
);
const { addons, database_included, homeassistant_included, folders } =
this._params.selectedData;
const restoreParams: RestoreBackupParams = {
await restoreBackup(this.hass, {
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 {
@@ -361,6 +350,9 @@ 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;

View File

@@ -33,18 +33,15 @@ 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 { getSignedPath } from "../../../data/auth";
import type { BackupConfig, BackupContent } from "../../../data/backup";
import {
compareAgents,
computeBackupAgentName,
computeBackupSize,
deleteBackup,
generateBackup,
generateBackupWithAutomaticSettings,
getBackupDownloadUrl,
getPreferredAgentForDownload,
isLocalAgent,
isNetworkMountAgent,
} from "../../../data/backup";
@@ -63,15 +60,13 @@ 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";
@@ -94,8 +89,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public config?: BackupConfig;
@property({ attribute: false }) public agents: BackupAgent[] = [];
@state() private _selected: string[] = [];
@storage({
@@ -179,7 +172,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
const name = computeBackupAgentName(
this.hass.localize,
agentId,
this.agents
backup.agent_ids
);
if (isLocalAgent(agentId)) {
return html`
@@ -295,8 +288,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
const type = backup.with_automatic_settings ? "automatic" : "manual";
return {
...backup,
size: computeBackupSize(backup),
agent_ids: Object.keys(backup.agents).sort(compareAgents),
formatted_type: localize(`ui.panel.config.backup.type.${type}`),
};
});
@@ -496,12 +487,12 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
private async _downloadBackup(backup: BackupContent): Promise<void> {
downloadBackup(
const preferedAgent = getPreferredAgentForDownload(backup!.agent_ids!);
const signedUrl = await getSignedPath(
this.hass,
this,
backup,
this.config?.create_backup.password
getBackupDownloadUrl(backup.backup_id, preferedAgent)
);
fileDownload(signedUrl.path);
}
private async _deleteBackup(backup: BackupContent): Promise<void> {

View File

@@ -20,19 +20,15 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import type {
BackupAgent,
BackupConfig,
BackupContentAgent,
BackupContentExtended,
BackupData,
} from "../../../data/backup";
import { getSignedPath } from "../../../data/auth";
import type { BackupContentExtended, BackupData } from "../../../data/backup";
import {
compareAgents,
computeBackupAgentName,
computeBackupSize,
deleteBackup,
fetchBackupDetails,
getBackupDownloadUrl,
getPreferredAgentForDownload,
isLocalAgent,
isNetworkMountAgent,
} from "../../../data/backup";
@@ -41,37 +37,27 @@ 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";
interface Agent extends BackupContentAgent {
interface Agent {
id: string;
success: boolean;
}
const computeAgents = (backup: BackupContentExtended) => {
const agentIds = Object.keys(backup.agents);
const failedAgentIds = backup.failed_agent_ids ?? [];
return [
...agentIds.filter((id) => !failedAgentIds.includes(id)),
...failedAgentIds,
const computeAgents = (agent_ids: string[], failed_agent_ids: string[]) =>
[
...agent_ids.filter((id) => !failed_agent_ids.includes(id)),
...failed_agent_ids,
]
.map<Agent>((id) => {
const agent: BackupContentAgent = backup.agents[id] ?? {
protected: false,
size: 0,
};
return {
...agent,
id: id,
success: !failedAgentIds.includes(id),
};
})
.map<Agent>((id) => ({
id,
success: !failed_agent_ids.includes(id),
}))
.sort((a, b) => compareAgents(a.id, b.id));
};
@customElement("ha-config-backup-details")
class HaConfigBackupDetails extends LitElement {
@@ -81,10 +67,6 @@ 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[] = [];
@@ -168,7 +150,7 @@ class HaConfigBackupDetails extends LitElement {
)}
</span>
<span slot="supporting-text">
${bytesToString(computeBackupSize(this._backup))}
${bytesToString(this._backup.size)}
</span>
</ha-md-list-item>
<ha-md-list-item>
@@ -185,6 +167,22 @@ 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>
@@ -226,112 +224,87 @@ 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.agents
this._backup!.agent_ids
);
const success = agent.success;
const failed = !agent.success;
const unencrypted = !agent.protected;
return html`
<ha-md-list-item>
${
isLocalAgent(agentId)
${isLocalAgent(agentId)
? html`
<ha-svg-icon
.path=${mdiHarddisk}
slot="start"
>
</ha-svg-icon>
`
: isNetworkMountAgent(agentId)
? html`
<ha-svg-icon
.path=${mdiHarddisk}
.path=${mdiNas}
slot="start"
>
</ha-svg-icon>
></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=${`${domain} logo`}
slot="start"
/>
`
}
: html`
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized:
this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
`}
<div slot="headline">${name}</div>
<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 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>
${
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>
`;
})}
@@ -375,7 +348,10 @@ class HaConfigBackupDetails extends LitElement {
try {
const response = await fetchBackupDetails(this.hass, this.backupId);
this._backup = response.backup;
this._agents = computeAgents(response.backup);
this._agents = computeAgents(
response.backup.agent_ids || [],
response.backup.failed_agent_ids || []
);
} catch (err: any) {
this._error =
err?.message ||
@@ -401,13 +377,13 @@ class HaConfigBackupDetails extends LitElement {
}
private async _downloadBackup(agentId?: string): Promise<void> {
await downloadBackup(
const preferedAgent =
agentId ?? getPreferredAgentForDownload(this._backup!.agent_ids!);
const signedUrl = await getSignedPath(
this.hass,
this,
this._backup!,
this.config?.create_backup.password,
agentId
getBackupDownloadUrl(this._backup!.backup_id, preferedAgent)
);
fileDownload(signedUrl.path);
}
private async _deleteBackup(): Promise<void> {
@@ -497,9 +473,6 @@ 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);
}

View File

@@ -1,368 +0,0 @@
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;
}
}

View File

@@ -13,14 +13,11 @@ 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";
@@ -56,8 +53,6 @@ class HaConfigBackupOverview extends LitElement {
@property({ attribute: false }) public config?: BackupConfig;
@property({ attribute: false }) public agents: BackupAgent[] = [];
private async _uploadBackup(ev) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
@@ -189,7 +184,6 @@ class HaConfigBackupOverview extends LitElement {
<ha-backup-overview-settings
.hass=${this.hass}
.config=${this.config!}
.agents=${this.agents}
></ha-backup-overview-settings>
`
: nothing}

View File

@@ -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 { BackupAgent, BackupConfig } from "../../../data/backup";
import type { BackupConfig } from "../../../data/backup";
import { updateBackupConfig } from "../../../data/backup";
import type { CloudStatus } from "../../../data/cloud";
import "../../../layouts/hass-subpage";
@@ -39,8 +39,6 @@ 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 {
@@ -179,11 +177,8 @@ 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`
@@ -313,7 +308,7 @@ class HaConfigBackupSettings extends LitElement {
password: this._config!.create_backup.password,
},
retention: this._config!.retention,
schedule: this._config!.schedule,
schedule: this._config!.schedule.state,
});
fireEvent(this, "ha-refresh-backup-config");
}

View File

@@ -1,14 +1,9 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
BackupAgent,
BackupConfig,
BackupContent,
} from "../../../data/backup";
import type { BackupConfig, BackupContent } from "../../../data/backup";
import {
compareAgents,
fetchBackupAgentsInfo,
fetchBackupConfig,
fetchBackupInfo,
} from "../../../data/backup";
@@ -46,8 +41,6 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
@state() private _backups: BackupContent[] = [];
@state() private _agents: BackupAgent[] = [];
@state() private _fetching = false;
@state() private _config?: BackupConfig;
@@ -61,20 +54,15 @@ 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(),
this._fetchBackupAgents(),
]).finally(() => {
this._fetching = false;
});
Promise.all([this._fetchBackupInfo(), this._fetchBackupConfig()]).finally(
() => {
this._fetching = false;
}
);
}
public connectedCallback() {
@@ -82,13 +70,16 @@ 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;
this._backups = info.backups.map((backup) => ({
...backup,
agent_ids: backup.agent_ids?.sort(compareAgents),
failed_agent_ids: backup.failed_agent_ids?.sort(compareAgents),
}));
}
private async _fetchBackupConfig() {
@@ -96,11 +87,6 @@ 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: {
@@ -120,10 +106,6 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
tag: "ha-config-backup-settings",
load: () => import("./ha-config-backup-settings"),
},
location: {
tag: "ha-config-backup-location",
load: () => import("./ha-config-backup-location"),
},
},
};
@@ -135,18 +117,13 @@ 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")) {
switch (this._currentPage) {
case "details":
pageEl.backupId = this.routeTail.path.substr(1);
break;
case "location":
pageEl.agentId = this.routeTail.path.substr(1);
break;
}
if (
(!changedProps || changedProps.has("route")) &&
this._currentPage === "details"
) {
pageEl.backupId = this.routeTail.path.substr(1);
}
}

View File

@@ -1,146 +0,0 @@
import type { LitElement } from "lit";
import {
canDecryptBackupOnDownload,
getBackupDownloadUrl,
getPreferredAgentForDownload,
type BackupContent,
} from "../../../../data/backup";
import type { HomeAssistant } from "../../../../types";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../lovelace/custom-card-helpers";
import { getSignedPath } from "../../../../data/auth";
import { fileDownload } from "../../../../util/file_download";
const triggerDownload = async (
hass: HomeAssistant,
backupId: string,
preferedAgent: string,
encryptionKey?: string | null
) => {
const signedUrl = await getSignedPath(
hass,
getBackupDownloadUrl(backupId, preferedAgent, encryptionKey)
);
fileDownload(signedUrl.path);
};
const downloadEncryptedBackup = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
agentId?: string
) => {
if (
await showConfirmationDialog(element, {
title: "Encryption key incorrect",
text: hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_entered_encryption_key"
),
confirmText: "Download encrypted",
})
) {
const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
triggerDownload(hass, backup.backup_id, preferedAgent);
}
};
const requestEncryptionKey = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
agentId?: string
): Promise<void> => {
const encryptionKey = await showPromptDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_current_encryption_key"
),
inputLabel: hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
),
inputType: "password",
confirmText: hass.localize("ui.common.download"),
});
if (encryptionKey === null) {
return;
}
downloadBackup(hass, element, backup, encryptionKey, agentId, true);
};
export const downloadBackup = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
encryptionKey?: string | null,
agentId?: string,
userProvided = false
): Promise<void> => {
const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
const isProtected = backup.agents[preferedAgent]?.protected;
if (isProtected) {
if (encryptionKey) {
try {
await canDecryptBackupOnDownload(
hass,
backup.backup_id,
preferedAgent,
encryptionKey
);
} catch (err: any) {
if (err?.code === "password_incorrect") {
if (userProvided) {
downloadEncryptedBackup(hass, element, backup, agentId);
} else {
requestEncryptionKey(hass, element, backup, agentId);
}
return;
}
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() {
triggerDownload(hass, backup.backup_id, preferedAgent);
},
});
encryptionKey = undefined;
return;
}
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,
}
),
});
return;
}
} else {
requestEncryptionKey(hass, element, backup, agentId);
return;
}
}
await triggerDownload(hass, backup.backup_id, preferedAgent, encryptionKey);
};

View File

@@ -1,5 +1,5 @@
import "@material/mwc-button/mwc-button";
import { mdiDelete, mdiDevices, mdiDrag, mdiPencil } from "@mdi/js";
import { mdiDelete, mdiDevices, mdiPencil } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { repeat } from "lit/directives/repeat";
@@ -7,6 +7,7 @@ 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 {
@@ -81,37 +82,41 @@ export class EnergyDeviceSettings extends LitElement {
"ui.panel.config.energy.device_consumption.devices"
)}
</h3>
<ha-sortable handle-selector=".handle" @item-moved=${this._itemMoved}>
<ha-sortable handle-selector=".row" @item-moved=${this._itemMoved}>
<div class="devices">
${repeat(
this.preferences.device_consumption,
(device) => device.stat_consumption,
(device) => html`
<div class="row" .device=${device}>
<div class="handle">
<ha-svg-icon .path=${mdiDrag}></ha-svg-icon>
(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>
</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>
@@ -209,7 +214,7 @@ export class EnergyDeviceSettings extends LitElement {
haStyle,
energyCardStyles,
css`
.handle {
.row {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
}

View File

@@ -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 { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time";
import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
@@ -410,7 +410,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
minWidth: "128px",
template: (entry) =>
entry.created_at
? formatShortDateTimeWithConditionalYear(
? formatShortDateTime(
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
? formatShortDateTimeWithConditionalYear(
? formatShortDateTime(
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 .clickAction=${this._bulkCreateLabel}>
<ha-md-menu-item @click=${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 .clickAction=${this._enableSelected}>
<ha-md-menu-item @click=${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 .clickAction=${this._disableSelected}>
<ha-md-menu-item @click=${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 .clickAction=${this._unhideSelected}>
<ha-md-menu-item @click=${this._unhideSelected}>
<ha-svg-icon
slot="start"
.path=${mdiEye}
@@ -876,7 +876,7 @@ ${
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._hideSelected}>
<ha-md-menu-item @click=${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 .clickAction=${this._removeSelected} class="warning">
<ha-md-menu-item @click=${this._removeSelected} class="warning">
<ha-svg-icon
slot="start"
.path=${mdiDelete}
@@ -1123,7 +1123,7 @@ ${
this._selected = ev.detail.value;
}
private _enableSelected = async () => {
private async _enableSelected() {
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 _removeSelected = async () => {
private async _removeSelected() {
if (!this._entities || !this.hass) {
return;
}
@@ -1369,7 +1369,7 @@ ${rejected
this._clearSelection();
},
});
};
}
private _clearSelection() {
this._dataTable.clearSelection();

View File

@@ -548,13 +548,6 @@ 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: () =>

View File

@@ -1,13 +1,13 @@
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { mdiPower } from "@mdi/js";
import type { ChartOptions } from "chart.js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { SeriesOption } from "echarts/types/dist/shared";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { numberFormatToLocale } from "../../../common/number/format_number";
import { round } from "../../../common/number/round";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/buttons/ha-progress-button";
@@ -38,22 +38,16 @@ import type { HomeAssistant } from "../../../types";
import { hardwareBrandsUrl } from "../../../util/brands-url";
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { ECOption } from "../../../resources/echarts";
import { getTimeAxisLabelConfig } from "../../../components/chart/axis-label";
const DATASAMPLES = 60;
const DATA_SET_CONFIG: SeriesOption = {
type: "line",
color: DEFAULT_PRIMARY_COLOR,
areaStyle: {
color: DEFAULT_PRIMARY_COLOR + "2B",
},
symbolSize: 0,
lineStyle: {
width: 1,
},
smooth: 0.25,
const DATA_SET_CONFIG = {
fill: "origin",
borderColor: DEFAULT_PRIMARY_COLOR,
backgroundColor: DEFAULT_PRIMARY_COLOR + "2B",
pointRadius: 0,
lineTension: 0.2,
borderWidth: 1,
};
@customElement("ha-config-hardware")
@@ -68,15 +62,15 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
@state() private _hardwareInfo?: HardwareInfo;
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: ChartOptions;
@state() private _systemStatusData?: SystemStatusStreamMessage;
@state() private _configEntries?: Record<string, ConfigEntry>;
private _memoryEntries: [number, number | null][] = [];
private _memoryEntries: { x: number; y: number | null }[] = [];
private _cpuEntries: [number, number | null][] = [];
private _cpuEntries: { x: number; y: number | null }[] = [];
public hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
const subs = [
@@ -127,14 +121,14 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
this._memoryEntries.shift();
this._cpuEntries.shift();
this._memoryEntries.push([
new Date(message.timestamp).getTime(),
message.memory_used_percent,
]);
this._cpuEntries.push([
new Date(message.timestamp).getTime(),
message.cpu_percent,
]);
this._memoryEntries.push({
x: new Date(message.timestamp).getTime(),
y: message.memory_used_percent,
});
this._cpuEntries.push({
x: new Date(message.timestamp).getTime(),
y: message.cpu_percent,
});
this._systemStatusData = message;
},
@@ -149,44 +143,51 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
}
protected willUpdate(): void {
if (!this.hasUpdated && !this._chartOptions) {
if (!this.hasUpdated) {
this._chartOptions = {
xAxis: {
type: "time",
axisLabel: getTimeAxisLabelConfig(this.hass.locale, this.hass.config),
splitLine: {
show: true,
responsive: true,
scales: {
y: {
gridLines: {
drawTicks: false,
},
ticks: {
maxTicksLimit: 7,
fontSize: 10,
max: 100,
min: 0,
stepSize: 1,
callback: (value) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
},
axisLine: {
show: false,
x: {
type: "time",
adapters: {
date: {
locale: this.hass.locale,
config: this.hass.config,
},
},
gridLines: {
display: true,
drawTicks: false,
},
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
fontSize: 10,
autoSkip: true,
maxTicksLimit: 5,
},
},
},
yAxis: {
type: "value",
splitLine: {
show: true,
},
axisLabel: {
formatter: (value: number) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
axisLine: {
show: false,
},
scale: true,
},
grid: {
top: 10,
bottom: 10,
left: 10,
right: 10,
containLabel: true,
},
tooltip: {
trigger: "axis",
valueFormatter: (value) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
};
}
}
@@ -200,8 +201,8 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
for (let i = 0; i < DATASAMPLES; i++) {
const t = new Date(date);
t.setSeconds(t.getSeconds() - 5 * (DATASAMPLES - i));
this._memoryEntries.push([t.getTime(), null]);
this._cpuEntries.push([t.getTime(), null]);
this._memoryEntries.push({ x: t.getTime(), y: null });
this._cpuEntries.push({ x: t.getTime(), y: null });
}
}
@@ -386,7 +387,14 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
<div class="card-content">
<ha-chart-base
.hass=${this.hass}
.data=${this._getChartData(this._cpuEntries)}
.data=${{
datasets: [
{
...DATA_SET_CONFIG,
data: this._cpuEntries,
},
],
}}
.options=${this._chartOptions}
></ha-chart-base>
</div>
@@ -411,7 +419,14 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
<div class="card-content">
<ha-chart-base
.hass=${this.hass}
.data=${this._getChartData(this._memoryEntries)}
.data=${{
datasets: [
{
...DATA_SET_CONFIG,
data: this._memoryEntries,
},
],
}}
.options=${this._chartOptions}
></ha-chart-base>
</div>
@@ -467,20 +482,6 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
showRestartDialog(this);
}
private _getChartData = memoizeOne(
(entries: [number, number | null][]): SeriesOption[] => [
{
...DATA_SET_CONFIG,
id: entries === this._cpuEntries ? "cpu" : "memory",
name:
entries === this._cpuEntries
? this.hass.localize("ui.panel.config.hardware.processor")
: this.hass.localize("ui.panel.config.hardware.memory"),
data: entries,
} as SeriesOption,
]
);
static styles = [
haStyle,
css`

View File

@@ -137,7 +137,6 @@ export class DialogHelperDetail extends LitElement {
this._error = undefined;
this._domain = undefined;
this._params = undefined;
this._filter = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}

View File

@@ -656,7 +656,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
).length}
.columns=${this._columns(this.hass.localize)}
.data=${helpers}
.initialGroupColumn=${this._activeGrouping ?? "category"}
.initialGroupColumn=${this._activeGrouping || "category"}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
.columnOrder=${this._activeColumnOrder}
@@ -1264,7 +1264,7 @@ ${rejected
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value ?? "";
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {

View File

@@ -156,9 +156,7 @@ class HaConfigInfo extends LitElement {
</ul>
</ha-card>
<ha-card outlined class="ohf">
<div>
${this.hass.localize("ui.panel.config.info.proud_part_of")}
</div>
<div>Proud part of</div>
<a
href="https://www.openhomefoundation.org"
target="_blank"

View File

@@ -319,6 +319,7 @@ class AddIntegrationDialog extends LitElement {
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
hideActions
.heading=${createCloseHeading(
this.hass,
@@ -448,14 +449,12 @@ class AddIntegrationDialog extends LitElement {
>
<lit-virtualizer
scroller
tabindex="-1"
class="ha-scrollbar"
style=${styleMap({
width: `${this._width}px`,
height: this._narrow ? "calc(100vh - 184px)" : "500px",
})}
@click=${this._integrationPicked}
@keypress=${this._handleKeyPress}
.items=${integrations}
.keyFunction=${this._keyFunction}
.renderItem=${this._renderRow}
@@ -479,7 +478,6 @@ class AddIntegrationDialog extends LitElement {
brand
.hass=${this.hass}
.integration=${integration}
tabindex="0"
>
</ha-integration-list-item>
`;
@@ -536,12 +534,6 @@ class AddIntegrationDialog extends LitElement {
this._handleIntegrationPicked(listItem.integration);
}
private _handleKeyPress(ev) {
if (ev.key === "Enter") {
this._integrationPicked(ev);
}
}
private async _handleIntegrationPicked(integration: IntegrationListItem) {
if (integration.supported_by) {
this._supportedBy(integration);

View File

@@ -1,146 +0,0 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-fab";
import "../../../../../components/ha-icon-button";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import type {
BluetoothDeviceData,
BluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import {
subscribeBluetoothAdvertisements,
subscribeBluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info";
@customElement("bluetooth-advertisement-monitor")
export class BluetoothAdvertisementMonitorPanel extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _data: BluetoothDeviceData[] = [];
@state() private _scanners: BluetoothScannersDetails = {};
private _unsub_advertisements?: UnsubscribeFunc;
private _unsub_scanners?: UnsubscribeFunc;
public connectedCallback(): void {
super.connectedCallback();
if (this.hass) {
this._unsub_advertisements = subscribeBluetoothAdvertisements(
this.hass.connection,
(data) => {
this._data = data;
}
);
this._unsub_scanners = subscribeBluetoothScannersDetails(
this.hass.connection,
(scanners) => {
this._scanners = scanners;
}
);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsub_advertisements) {
this._unsub_advertisements();
this._unsub_advertisements = undefined;
}
if (this._unsub_scanners) {
this._unsub_scanners();
this._unsub_scanners = undefined;
}
}
private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer => {
const columns: DataTableColumnContainer<BluetoothDeviceData> = {
address: {
title: localize("ui.panel.config.bluetooth.address"),
sortable: true,
filterable: true,
showNarrow: true,
main: true,
hideable: false,
moveable: false,
direction: "asc",
flex: 1,
},
name: {
title: localize("ui.panel.config.bluetooth.name"),
filterable: true,
sortable: true,
},
source: {
title: localize("ui.panel.config.bluetooth.source"),
filterable: true,
sortable: true,
},
rssi: {
title: localize("ui.panel.config.bluetooth.rssi"),
type: "numeric",
sortable: true,
},
};
return columns;
}
);
private _dataWithNamedSourceAndIds = memoizeOne((data) =>
data.map((row) => ({
...row,
id: row.address,
source: this._scanners[row.source]?.name || row.source,
}))
);
protected render(): TemplateResult {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.columns=${this._columns(this.hass.localize)}
.data=${this._dataWithNamedSourceAndIds(this._data)}
@row-click=${this._handleRowClicked}
clickable
></hass-tabs-subpage-data-table>
`;
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const entry = this._data.find((ent) => ent.address === ev.detail.id);
showBluetoothDeviceInfoDialog(this, {
entry: entry!,
});
}
static styles: CSSResultGroup = haStyle;
}
declare global {
interface HTMLElementTagNameMap {
"bluetooth-advertisement-monitor": BluetoothAdvertisementMonitorPanel;
}
}

View File

@@ -1,46 +0,0 @@
import { customElement, property } from "lit/decorators";
import type { RouterOptions } from "../../../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../../../layouts/hass-router-page";
import type { HomeAssistant } from "../../../../../types";
@customElement("bluetooth-config-dashboard-router")
class BluetoothConfigDashboardRouter extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
private _configEntry = new URLSearchParams(window.location.search).get(
"config_entry"
);
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
showLoading: true,
routes: {
dashboard: {
tag: "bluetooth-config-dashboard",
load: () => import("./bluetooth-config-dashboard"),
},
"advertisement-monitor": {
tag: "bluetooth-advertisement-monitor",
load: () => import("./bluetooth-advertisement-monitor"),
},
},
};
protected updatePageEl(el): void {
el.route = this.routeTail;
el.hass = this.hass;
el.isWide = this.isWide;
el.narrow = this.narrow;
el.configEntryId = this._configEntry;
}
}
declare global {
interface HTMLElementTagNameMap {
"bluetooth-config-dashboard-router": BluetoothConfigDashboardRouter;
}
}

View File

@@ -1,211 +0,0 @@
import "@material/mwc-button";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-card";
import "../../../../../components/ha-code-editor";
import "../../../../../components/ha-formfield";
import "../../../../../components/ha-switch";
import { getConfigEntries } from "../../../../../data/config_entries";
import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { subscribeBluetoothConnectionAllocations } from "../../../../../data/bluetooth";
import {
getValueInPercentage,
roundWithOneDecimal,
} from "../../../../../util/calculate";
import "../../../../../components/ha-metric";
import type { BluetoothAllocationsData } from "../../../../../data/bluetooth";
@customElement("bluetooth-config-dashboard")
export class BluetoothConfigDashboard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _connectionAllocationData: BluetoothAllocationsData[] = [];
@state() private _connectionAllocationsError?: string;
private _configEntry = new URLSearchParams(window.location.search).get(
"config_entry"
);
private _unsubConnectionAllocations?: (() => Promise<void>) | undefined;
public connectedCallback(): void {
super.connectedCallback();
if (this.hass) {
this._subscribeBluetoothConnectionAllocations();
}
}
private async _subscribeBluetoothConnectionAllocations(): Promise<void> {
if (this._unsubConnectionAllocations || !this._configEntry) {
return;
}
try {
this._unsubConnectionAllocations =
await subscribeBluetoothConnectionAllocations(
this.hass.connection,
(data) => {
this._connectionAllocationData = data;
},
this._configEntry
);
} catch (err: any) {
this._unsubConnectionAllocations = undefined;
this._connectionAllocationsError = err.message;
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubConnectionAllocations) {
this._unsubConnectionAllocations();
this._unsubConnectionAllocations = undefined;
}
}
protected render(): TemplateResult {
return html`
<hass-subpage .narrow=${this.narrow} .hass=${this.hass}>
<div class="content">
<ha-card
.header=${this.hass.localize(
"ui.panel.config.bluetooth.settings_title"
)}
>
<div class="card-actions">
<mwc-button @click=${this._openOptionFlow}
>${this.hass.localize(
"ui.panel.config.bluetooth.option_flow"
)}</mwc-button
>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.bluetooth.advertisement_monitor"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.bluetooth.advertisement_monitor_details"
)}
</p>
</div>
<div class="card-actions">
<a href="/config/bluetooth/advertisement-monitor"
><mwc-button>
${this.hass.localize(
"ui.panel.config.bluetooth.advertisement_monitor"
)}
</mwc-button></a
>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.bluetooth.connection_slot_allocations_monitor"
)}
>
<div class="card-content">
${this._renderConnectionAllocations()}
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
private _getUsedAllocations = (used: number, total: number) =>
roundWithOneDecimal(getValueInPercentage(used, 0, total));
private _renderConnectionAllocations() {
if (this._connectionAllocationsError) {
return html`<ha-alert alert-type="error"
>${this._connectionAllocationsError}</ha-alert
>`;
}
if (this._connectionAllocationData.length === 0) {
return html`<div>
${this.hass.localize(
"ui.panel.config.bluetooth.no_connection_slot_allocations"
)}
</div>`;
}
const allocations = this._connectionAllocationData[0];
const allocationsUsed = allocations.slots - allocations.free;
const allocationsTotal = allocations.slots;
if (allocationsTotal === 0) {
return html`<div>
${this.hass.localize(
"ui.panel.config.bluetooth.no_active_connection_support"
)}
</div>`;
}
return html`
<p>
${this.hass.localize(
"ui.panel.config.bluetooth.connection_slot_allocations_monitor_details",
{ slots: allocationsTotal }
)}
</p>
<ha-metric
.heading=${this.hass.localize(
"ui.panel.config.bluetooth.used_connection_slot_allocations"
)}
.value=${this._getUsedAllocations(allocationsUsed, allocationsTotal)}
.tooltip=${allocations.allocated.length > 0
? `${allocationsUsed}/${allocationsTotal} (${allocations.allocated.join(", ")})`
: `${allocationsUsed}/${allocationsTotal}`}
></ha-metric>
`;
}
private async _openOptionFlow() {
const configEntryId = this._configEntry;
if (!configEntryId) {
return;
}
const configEntries = await getConfigEntries(this.hass, {
domain: "bluetooth",
});
const configEntry = configEntries.find(
(entry) => entry.entry_id === configEntryId
);
showOptionsFlowDialog(this, configEntry!);
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.content {
padding: 24px 0 32px;
max-width: 600px;
margin: 0 auto;
direction: ltr;
}
ha-card {
margin-bottom: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"bluetooth-config-dashboard": BluetoothConfigDashboard;
}
}

View File

@@ -1,126 +0,0 @@
import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { HassDialog } from "../../../../../dialogs/make-dialog-manager";
import { createCloseHeading } from "../../../../../components/ha-dialog";
import type { HomeAssistant } from "../../../../../types";
import type { BluetoothDeviceInfoDialogParams } from "./show-dialog-bluetooth-device-info";
import "../../../../../components/ha-button";
import { showToast } from "../../../../../util/toast";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
@customElement("dialog-bluetooth-device-info")
class DialogBluetoothDeviceInfo extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: BluetoothDeviceInfoDialogParams;
public async showDialog(
params: BluetoothDeviceInfoDialogParams
): Promise<void> {
this._params = params;
}
public closeDialog(): boolean {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
return true;
}
public showDataAsHex(bytestring: string): string {
return Array.from(new TextEncoder().encode(bytestring))
.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0"))
.join(" ");
}
private async _copyToClipboard(): Promise<void> {
if (!this._params) {
return;
}
await copyToClipboard(JSON.stringify(this._params!.entry));
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
protected render(): TemplateResult | typeof nothing {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize("ui.panel.config.bluetooth.device_information")
)}
>
<p>
<b>${this.hass.localize("ui.panel.config.bluetooth.address")}</b>:
${this._params.entry.address}
<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.name")}</b>:
${this._params.entry.name}
<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.source")}</b>:
${this._params.entry.source}
</p>
<h3>
${this.hass.localize("ui.panel.config.bluetooth.advertisement_data")}
</h3>
<h4>
${this.hass.localize("ui.panel.config.bluetooth.manufacturer_data")}
</h4>
<table width="100%">
<tbody>
${Object.entries(this._params.entry.manufacturer_data).map(
([key, value]) => html`
<tr>
${key}
</tr>
<tr>
${this.showDataAsHex(value)}
</tr>
`
)}
</tbody>
</table>
<h4>${this.hass.localize("ui.panel.config.bluetooth.service_data")}</h4>
<table width="100%">
<tbody>
${Object.entries(this._params.entry.service_data).map(
([key, value]) => html`
<tr>
${key}
</tr>
<tr>
${this.showDataAsHex(value)}
</tr>
`
)}
</tbody>
</table>
<ha-button slot="secondaryAction" @click=${this._copyToClipboard}
>${this.hass.localize(
"ui.panel.config.bluetooth.copy_to_clipboard"
)}</ha-button
>
</ha-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-bluetooth-device-info": DialogBluetoothDeviceInfo;
}
}

View File

@@ -1,20 +0,0 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { BluetoothDeviceData } from "../../../../../data/bluetooth";
export interface BluetoothDeviceInfoDialogParams {
entry: BluetoothDeviceData;
}
export const loadBluetoothDeviceInfoDialog = () =>
import("./dialog-bluetooth-device-info");
export const showBluetoothDeviceInfoDialog = (
element: HTMLElement,
bluetoothDeviceInfoDialogParams: BluetoothDeviceInfoDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-bluetooth-device-info",
dialogImport: loadBluetoothDeviceInfoDialog,
dialogParams: bluetoothDeviceInfoDialogParams,
});
};

View File

@@ -800,8 +800,8 @@ class ErrorLogCard extends LitElement {
font-family: var(--code-font-family, monospace);
clear: both;
text-align: start;
padding-top: 16px;
padding-bottom: 16px;
padding-top: 12px;
padding-bottom: 12px;
overflow-y: scroll;
min-height: var(--error-log-card-height, calc(100vh - 240px));
max-height: var(--error-log-card-height, calc(100vh - 240px));

View File

@@ -260,7 +260,7 @@ export class SystemLogCard extends LitElement {
.header-buttons {
display: flex;
align-items: flex-start;
align-items: center;
}
.card-header {
@@ -271,6 +271,7 @@ export class SystemLogCard extends LitElement {
line-height: 48px;
display: block;
margin-block-start: 0px;
margin-block-end: 0px;
font-weight: normal;
}
@@ -295,8 +296,6 @@ export class SystemLogCard extends LitElement {
.card-content {
border-top: 1px solid var(--divider-color);
padding-top: 16px;
padding-bottom: 16px;
}
.row-secondary {

View File

@@ -552,7 +552,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
).length}
.columns=${this._columns(this.hass.localize)}
id="entity_id"
.initialGroupColumn=${this._activeGrouping ?? "category"}
.initialGroupColumn=${this._activeGrouping || "category"}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
.columnOrder=${this._activeColumnOrder}
@@ -1163,7 +1163,7 @@ ${rejected
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value ?? "";
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {

View File

@@ -62,8 +62,8 @@ import { haStyle } from "../../../resources/styles";
import type { Entries, HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import { showAutomationModeDialog } from "../automation/automation-mode-dialog/show-dialog-automation-mode";
import type { EntityRegistryUpdate } from "../automation/automation-save-dialog/show-dialog-automation-save";
import { showAutomationSaveDialog } from "../automation/automation-save-dialog/show-dialog-automation-save";
import type { EntityRegistryUpdate } from "../automation/automation-rename-dialog/show-dialog-automation-rename";
import { showAutomationRenameDialog } from "../automation/automation-rename-dialog/show-dialog-automation-rename";
import "./blueprint-script-editor";
import "./manual-script-editor";
import type { HaManualScriptEditor } from "./manual-script-editor";
@@ -452,7 +452,7 @@ export class HaScriptEditor extends SubscribeMixin(
)}
.disabled=${this._saving}
extended
@click=${this._handleSave}
@click=${this._saveScript}
>
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
</ha-fab>
@@ -707,48 +707,20 @@ export class HaScriptEditor extends SubscribeMixin(
}
private async _confirmUnsavedChanged(): Promise<boolean> {
if (!this._dirty) {
return true;
}
return new Promise<boolean>((resolve) => {
showAutomationSaveDialog(this, {
config: this._config!,
domain: "script",
updateConfig: async (config, entityRegistryUpdate) => {
this._config = config;
this._entityRegistryUpdate = entityRegistryUpdate;
this._dirty = true;
this.requestUpdate();
const id = this.scriptId || String(Date.now());
try {
await this._saveScript(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.scriptId
? "ui.panel.config.script.editor.leave.unsaved_confirm_title"
: "ui.panel.config.script.editor.leave.unsaved_new_title"
if (this._dirty) {
return showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.automation.editor.unsaved_confirm_title"
),
description: this.hass.localize(
this.scriptId
? "ui.panel.config.script.editor.leave.unsaved_confirm_text"
: "ui.panel.config.script.editor.leave.unsaved_new_text"
text: this.hass!.localize(
"ui.panel.config.automation.editor.unsaved_confirm_text"
),
hideInputs: this.scriptId !== null,
confirmText: this.hass!.localize("ui.common.leave"),
dismissText: this.hass!.localize("ui.common.stay"),
destructive: true,
});
});
}
return true;
}
private _backTapped = async () => {
@@ -871,10 +843,10 @@ export class HaScriptEditor extends SubscribeMixin(
private async _promptScriptAlias(): Promise<boolean> {
return new Promise((resolve) => {
showAutomationSaveDialog(this, {
showAutomationRenameDialog(this, {
config: this._config!,
domain: "script",
updateConfig: async (config, entityRegistryUpdate) => {
updateConfig: (config, entityRegistryUpdate) => {
this._config = config;
this._entityRegistryUpdate = entityRegistryUpdate;
this._dirty = true;
@@ -905,7 +877,7 @@ export class HaScriptEditor extends SubscribeMixin(
});
}
private async _handleSave() {
private async _saveScript(): Promise<void> {
if (this._yamlErrors) {
showToast(this, {
message: this._yamlErrors,
@@ -922,13 +894,6 @@ export class HaScriptEditor extends SubscribeMixin(
}
const id = this.scriptId || this._entityId || Date.now();
await this._saveScript(id);
if (!this.scriptId) {
navigate(`/config/script/edit/${id}`, { replace: true });
}
}
private async _saveScript(id): Promise<void> {
this._saving = true;
let entityRegPromise: Promise<EntityRegistryEntry> | undefined;
@@ -997,6 +962,10 @@ export class HaScriptEditor extends SubscribeMixin(
}
this._dirty = false;
if (!this.scriptId) {
navigate(`/config/script/edit/${id}`, { replace: true });
}
} catch (errors: any) {
this._errors = errors.body?.message || errors.error || errors.body;
showToast(this, {
@@ -1010,7 +979,7 @@ export class HaScriptEditor extends SubscribeMixin(
protected supportedShortcuts(): SupportedShortcuts {
return {
s: () => this._handleSave(),
s: () => this._saveScript(),
};
}

View File

@@ -527,7 +527,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
{ number: scripts.length }
)}
has-filters
.initialGroupColumn=${this._activeGrouping ?? "category"}
.initialGroupColumn=${this._activeGrouping || "category"}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
.columnOrder=${this._activeColumnOrder}
@@ -1255,7 +1255,7 @@ ${rejected
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value ?? "";
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {

View File

@@ -121,9 +121,7 @@ export class AssistPref extends LitElement {
class="icon-link"
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.link_learn_how_it_works"
)}
label="Learn how it works"
.path=${mdiHelpCircle}
></ha-icon-button>
</a>

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