mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-27 03:06:41 +00:00
20250205.0 (#24088)
This commit is contained in:
commit
9d7d332790
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "home-assistant-frontend"
|
name = "home-assistant-frontend"
|
||||||
version = "20250129.0"
|
version = "20250205.0"
|
||||||
license = {text = "Apache-2.0"}
|
license = {text = "Apache-2.0"}
|
||||||
description = "The Home Assistant frontend"
|
description = "The Home Assistant frontend"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import memoizeOne from "memoize-one";
|
||||||
import { theme2hex } from "./convert-color";
|
import { theme2hex } from "./convert-color";
|
||||||
|
|
||||||
export const COLORS = [
|
export const COLORS = [
|
||||||
@ -74,3 +75,12 @@ export function getGraphColorByIndex(
|
|||||||
getColorByIndex(index);
|
getColorByIndex(index);
|
||||||
return theme2hex(themeColor);
|
return theme2hex(themeColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getAllGraphColors = memoizeOne(
|
||||||
|
(style: CSSStyleDeclaration) =>
|
||||||
|
COLORS.map((_color, index) => getGraphColorByIndex(index, style)),
|
||||||
|
(newArgs: [CSSStyleDeclaration], lastArgs: [CSSStyleDeclaration]) =>
|
||||||
|
// this is not ideal, but we need to memoize the colors
|
||||||
|
newArgs[0].getPropertyValue("--graph-color-1") ===
|
||||||
|
lastArgs[0].getPropertyValue("--graph-color-1")
|
||||||
|
);
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import type { HassConfig } from "home-assistant-js-websocket";
|
import type { HassConfig } from "home-assistant-js-websocket";
|
||||||
import type { XAXisOption } from "echarts/types/dist/shared";
|
|
||||||
import type { FrontendLocaleData } from "../../data/translation";
|
import type { FrontendLocaleData } from "../../data/translation";
|
||||||
import {
|
import {
|
||||||
formatDateMonth,
|
formatDateMonth,
|
||||||
@ -7,56 +6,46 @@ import {
|
|||||||
formatDateVeryShort,
|
formatDateVeryShort,
|
||||||
formatDateWeekdayShort,
|
formatDateWeekdayShort,
|
||||||
} from "../../common/datetime/format_date";
|
} from "../../common/datetime/format_date";
|
||||||
import { formatTime } from "../../common/datetime/format_time";
|
import {
|
||||||
|
formatTime,
|
||||||
|
formatTimeWithSeconds,
|
||||||
|
} from "../../common/datetime/format_time";
|
||||||
|
|
||||||
export function getLabelFormatter(
|
export function formatTimeLabel(
|
||||||
|
value: number | Date,
|
||||||
locale: FrontendLocaleData,
|
locale: FrontendLocaleData,
|
||||||
config: HassConfig,
|
config: HassConfig,
|
||||||
dayDifference = 0
|
minutesDifference: number
|
||||||
) {
|
) {
|
||||||
return (value: number | Date) => {
|
const dayDifference = minutesDifference / 60 / 24;
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (dayDifference > 88) {
|
if (dayDifference > 88) {
|
||||||
return date.getMonth() === 0
|
return date.getMonth() === 0
|
||||||
? `{bold|${formatDateMonthYear(date, locale, config)}}`
|
? `{bold|${formatDateMonthYear(date, locale, config)}}`
|
||||||
: formatDateMonth(date, locale, config);
|
: formatDateMonth(date, locale, config);
|
||||||
}
|
}
|
||||||
if (dayDifference > 35) {
|
if (dayDifference > 35) {
|
||||||
return date.getDate() === 1
|
return date.getDate() === 1
|
||||||
? `{bold|${formatDateVeryShort(date, locale, config)}}`
|
? `{bold|${formatDateVeryShort(date, locale, config)}}`
|
||||||
: formatDateVeryShort(date, locale, config);
|
: formatDateVeryShort(date, locale, config);
|
||||||
}
|
}
|
||||||
if (dayDifference > 7) {
|
if (dayDifference > 7) {
|
||||||
const label = formatDateVeryShort(date, locale, config);
|
const label = formatDateVeryShort(date, locale, config);
|
||||||
return date.getDate() === 1 ? `{bold|${label}}` : label;
|
return date.getDate() === 1 ? `{bold|${label}}` : label;
|
||||||
}
|
}
|
||||||
if (dayDifference > 2) {
|
if (dayDifference > 2) {
|
||||||
return formatDateWeekdayShort(date, locale, config);
|
return formatDateWeekdayShort(date, locale, config);
|
||||||
}
|
}
|
||||||
|
if (minutesDifference && minutesDifference < 5) {
|
||||||
|
return formatTimeWithSeconds(date, locale, config);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
date.getHours() === 0 &&
|
||||||
|
date.getMinutes() === 0 &&
|
||||||
|
date.getSeconds() === 0
|
||||||
|
) {
|
||||||
// show only date for the beginning of the day
|
// show only date for the beginning of the day
|
||||||
if (
|
return `{bold|${formatDateVeryShort(date, locale, config)}}`;
|
||||||
date.getHours() === 0 &&
|
}
|
||||||
date.getMinutes() === 0 &&
|
return formatTime(date, locale, config);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,29 @@
|
|||||||
import type { PropertyValues } from "lit";
|
import { consume } from "@lit-labs/context";
|
||||||
import { css, html, nothing, LitElement } from "lit";
|
|
||||||
import { customElement, property, state } from "lit/decorators";
|
|
||||||
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 { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||||
|
import { mdiRestart } from "@mdi/js";
|
||||||
|
import { differenceInMinutes } from "date-fns";
|
||||||
|
import type { DataZoomComponentOption } from "echarts/components";
|
||||||
|
import type { EChartsType } from "echarts/core";
|
||||||
import type {
|
import type {
|
||||||
ECElementEvent,
|
ECElementEvent,
|
||||||
XAXisOption,
|
XAXisOption,
|
||||||
YAXisOption,
|
YAXisOption,
|
||||||
} from "echarts/types/dist/shared";
|
} from "echarts/types/dist/shared";
|
||||||
import { consume } from "@lit-labs/context";
|
import type { PropertyValues } from "lit";
|
||||||
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators";
|
||||||
|
import { classMap } from "lit/directives/class-map";
|
||||||
|
import { styleMap } from "lit/directives/style-map";
|
||||||
|
import { getAllGraphColors } from "../../common/color/colors";
|
||||||
import { fireEvent } from "../../common/dom/fire_event";
|
import { fireEvent } from "../../common/dom/fire_event";
|
||||||
|
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||||
|
import { themesContext } from "../../data/context";
|
||||||
|
import type { Themes } from "../../data/ws-themes";
|
||||||
|
import type { ECOption } from "../../resources/echarts";
|
||||||
import type { HomeAssistant } from "../../types";
|
import type { HomeAssistant } from "../../types";
|
||||||
import { isMac } from "../../util/is_mac";
|
import { isMac } from "../../util/is_mac";
|
||||||
import "../ha-icon-button";
|
import "../ha-icon-button";
|
||||||
import type { ECOption } from "../../resources/echarts";
|
import { formatTimeLabel } from "./axis-label";
|
||||||
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;
|
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
|
||||||
|
|
||||||
@ -44,6 +48,10 @@ export class HaChartBase extends LitElement {
|
|||||||
|
|
||||||
@state() private _isZoomed = false;
|
@state() private _isZoomed = false;
|
||||||
|
|
||||||
|
@state() private _zoomRatio = 1;
|
||||||
|
|
||||||
|
@state() private _minutesDifference = 24 * 60;
|
||||||
|
|
||||||
private _modifierPressed = false;
|
private _modifierPressed = false;
|
||||||
|
|
||||||
private _isTouchDevice = "ontouchstart" in window;
|
private _isTouchDevice = "ontouchstart" in window;
|
||||||
@ -135,16 +143,7 @@ export class HaChartBase extends LitElement {
|
|||||||
this.chart.setOption(this._createOptions(), {
|
this.chart.setOption(this._createOptions(), {
|
||||||
lazyUpdate: true,
|
lazyUpdate: true,
|
||||||
// if we replace the whole object, it will reset the dataZoom
|
// if we replace the whole object, it will reset the dataZoom
|
||||||
replaceMerge: [
|
replaceMerge: ["grid"],
|
||||||
"xAxis",
|
|
||||||
"yAxis",
|
|
||||||
"dataZoom",
|
|
||||||
"dataset",
|
|
||||||
"tooltip",
|
|
||||||
"legend",
|
|
||||||
"grid",
|
|
||||||
"visualMap",
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -152,7 +151,10 @@ export class HaChartBase extends LitElement {
|
|||||||
protected render() {
|
protected render() {
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="chart-container"
|
class=${classMap({
|
||||||
|
"chart-container": true,
|
||||||
|
"has-legend": !!this.options?.legend,
|
||||||
|
})}
|
||||||
style=${styleMap({
|
style=${styleMap({
|
||||||
height: this.height ?? `${this._getDefaultHeight()}px`,
|
height: this.height ?? `${this._getDefaultHeight()}px`,
|
||||||
})}
|
})}
|
||||||
@ -173,6 +175,14 @@ export class HaChartBase extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _formatTimeLabel = (value: number | Date) =>
|
||||||
|
formatTimeLabel(
|
||||||
|
value,
|
||||||
|
this.hass.locale,
|
||||||
|
this.hass.config,
|
||||||
|
this._minutesDifference * this._zoomRatio
|
||||||
|
);
|
||||||
|
|
||||||
private async _setupChart() {
|
private async _setupChart() {
|
||||||
if (this._loading) return;
|
if (this._loading) return;
|
||||||
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
|
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
|
||||||
@ -183,10 +193,9 @@ export class HaChartBase extends LitElement {
|
|||||||
}
|
}
|
||||||
const echarts = (await import("../../resources/echarts")).default;
|
const echarts = (await import("../../resources/echarts")).default;
|
||||||
|
|
||||||
this.chart = echarts.init(
|
echarts.registerTheme("custom", this._createTheme());
|
||||||
container,
|
|
||||||
this._themes.darkMode ? "dark" : "light"
|
this.chart = echarts.init(container, "custom");
|
||||||
);
|
|
||||||
this.chart.on("legendselectchanged", (params: any) => {
|
this.chart.on("legendselectchanged", (params: any) => {
|
||||||
if (this.externalHidden) {
|
if (this.externalHidden) {
|
||||||
const isSelected = params.selected[params.name];
|
const isSelected = params.selected[params.name];
|
||||||
@ -200,6 +209,7 @@ export class HaChartBase extends LitElement {
|
|||||||
this.chart.on("datazoom", (e: any) => {
|
this.chart.on("datazoom", (e: any) => {
|
||||||
const { start, end } = e.batch?.[0] ?? e;
|
const { start, end } = e.batch?.[0] ?? e;
|
||||||
this._isZoomed = start !== 0 || end !== 100;
|
this._isZoomed = start !== 0 || end !== 100;
|
||||||
|
this._zoomRatio = (end - start) / 100;
|
||||||
});
|
});
|
||||||
this.chart.on("click", (e: ECElementEvent) => {
|
this.chart.on("click", (e: ECElementEvent) => {
|
||||||
fireEvent(this, "chart-click", e);
|
fireEvent(this, "chart-click", e);
|
||||||
@ -237,24 +247,60 @@ export class HaChartBase extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _createOptions(): ECOption {
|
private _createOptions(): ECOption {
|
||||||
const darkMode = this._themes.darkMode ?? false;
|
let xAxis = this.options?.xAxis;
|
||||||
|
if (xAxis) {
|
||||||
|
xAxis = Array.isArray(xAxis) ? xAxis : [xAxis];
|
||||||
|
xAxis = xAxis.map((axis: XAXisOption) => {
|
||||||
|
if (axis.type !== "time" || axis.show === false) {
|
||||||
|
return axis;
|
||||||
|
}
|
||||||
|
if (axis.max && axis.min) {
|
||||||
|
this._minutesDifference = differenceInMinutes(
|
||||||
|
axis.max as Date,
|
||||||
|
axis.min as Date
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const dayDifference = this._minutesDifference / 60 / 24;
|
||||||
|
let minInterval: number | undefined;
|
||||||
|
if (dayDifference) {
|
||||||
|
minInterval =
|
||||||
|
dayDifference >= 89 // quarter
|
||||||
|
? 28 * 3600 * 24 * 1000
|
||||||
|
: dayDifference > 2
|
||||||
|
? 3600 * 24 * 1000
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
axisLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
...axis,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: this._formatTimeLabel,
|
||||||
|
rich: {
|
||||||
|
bold: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hideOverlap: true,
|
||||||
|
...axis.axisLabel,
|
||||||
|
},
|
||||||
|
minInterval,
|
||||||
|
} as XAXisOption;
|
||||||
|
});
|
||||||
|
}
|
||||||
const options = {
|
const options = {
|
||||||
backgroundColor: "transparent",
|
|
||||||
animation: !this._reducedMotion,
|
animation: !this._reducedMotion,
|
||||||
darkMode,
|
darkMode: this._themes.darkMode ?? false,
|
||||||
aria: {
|
aria: {
|
||||||
show: true,
|
show: true,
|
||||||
},
|
},
|
||||||
dataZoom: this._getDataZoomConfig(),
|
dataZoom: this._getDataZoomConfig(),
|
||||||
...this.options,
|
...this.options,
|
||||||
legend: this.options?.legend
|
xAxis,
|
||||||
? {
|
|
||||||
// we should create our own theme but this is a quick fix for now
|
|
||||||
inactiveColor: darkMode ? "#444" : "#ccc",
|
|
||||||
...this.options.legend,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMobile = window.matchMedia(
|
const isMobile = window.matchMedia(
|
||||||
@ -268,18 +314,206 @@ export class HaChartBase extends LitElement {
|
|||||||
tooltips.forEach((tooltip) => {
|
tooltips.forEach((tooltip) => {
|
||||||
tooltip.confine = true;
|
tooltip.confine = true;
|
||||||
tooltip.appendTo = undefined;
|
tooltip.appendTo = undefined;
|
||||||
|
tooltip.triggerOn = "click";
|
||||||
});
|
});
|
||||||
options.tooltip = tooltips;
|
options.tooltip = tooltips;
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _createTheme() {
|
||||||
|
const style = getComputedStyle(this);
|
||||||
|
return {
|
||||||
|
color: getAllGraphColors(style),
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
textStyle: {
|
||||||
|
color: style.getPropertyValue("--primary-text-color"),
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
textStyle: {
|
||||||
|
color: style.getPropertyValue("--primary-text-color"),
|
||||||
|
},
|
||||||
|
subtextStyle: {
|
||||||
|
color: style.getPropertyValue("--secondary-text-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 1.5,
|
||||||
|
},
|
||||||
|
symbolSize: 1,
|
||||||
|
symbol: "circle",
|
||||||
|
smooth: false,
|
||||||
|
},
|
||||||
|
bar: {
|
||||||
|
itemStyle: {
|
||||||
|
barBorderWidth: 1.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
categoryAxis: {
|
||||||
|
axisLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
show: true,
|
||||||
|
color: style.getPropertyValue("--primary-text-color"),
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: false,
|
||||||
|
lineStyle: {
|
||||||
|
color: style.getPropertyValue("--divider-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
splitArea: {
|
||||||
|
show: false,
|
||||||
|
areaStyle: {
|
||||||
|
color: [
|
||||||
|
style.getPropertyValue("--divider-color") + "3F",
|
||||||
|
style.getPropertyValue("--divider-color") + "7F",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
valueAxis: {
|
||||||
|
axisLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: style.getPropertyValue("--divider-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: style.getPropertyValue("--divider-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
show: true,
|
||||||
|
color: style.getPropertyValue("--primary-text-color"),
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: style.getPropertyValue("--divider-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
splitArea: {
|
||||||
|
show: false,
|
||||||
|
areaStyle: {
|
||||||
|
color: [
|
||||||
|
style.getPropertyValue("--divider-color") + "3F",
|
||||||
|
style.getPropertyValue("--divider-color") + "7F",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logAxis: {
|
||||||
|
axisLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: style.getPropertyValue("--divider-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: style.getPropertyValue("--divider-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
show: true,
|
||||||
|
color: style.getPropertyValue("--primary-text-color"),
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: style.getPropertyValue("--divider-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
splitArea: {
|
||||||
|
show: false,
|
||||||
|
areaStyle: {
|
||||||
|
color: [
|
||||||
|
style.getPropertyValue("--divider-color") + "3F",
|
||||||
|
style.getPropertyValue("--divider-color") + "7F",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timeAxis: {
|
||||||
|
axisLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: style.getPropertyValue("--divider-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: style.getPropertyValue("--divider-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
show: true,
|
||||||
|
color: style.getPropertyValue("--primary-text-color"),
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: style.getPropertyValue("--divider-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
splitArea: {
|
||||||
|
show: false,
|
||||||
|
areaStyle: {
|
||||||
|
color: [
|
||||||
|
style.getPropertyValue("--divider-color") + "3F",
|
||||||
|
style.getPropertyValue("--divider-color") + "7F",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
textStyle: {
|
||||||
|
color: style.getPropertyValue("--primary-text-color"),
|
||||||
|
},
|
||||||
|
inactiveColor: style.getPropertyValue("--disabled-text-color"),
|
||||||
|
pageIconColor: style.getPropertyValue("--primary-text-color"),
|
||||||
|
pageIconInactiveColor: style.getPropertyValue("--disabled-text-color"),
|
||||||
|
pageTextStyle: {
|
||||||
|
color: style.getPropertyValue("--secondary-text-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: style.getPropertyValue("--card-background-color"),
|
||||||
|
borderColor: style.getPropertyValue("--divider-color"),
|
||||||
|
textStyle: {
|
||||||
|
color: style.getPropertyValue("--primary-text-color"),
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
axisPointer: {
|
||||||
|
lineStyle: {
|
||||||
|
color: style.getPropertyValue("--divider-color"),
|
||||||
|
},
|
||||||
|
crossStyle: {
|
||||||
|
color: style.getPropertyValue("--divider-color"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timeline: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private _getDefaultHeight() {
|
private _getDefaultHeight() {
|
||||||
return Math.max(this.clientWidth / 2, 400);
|
return Math.max(this.clientWidth / 2, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleZoomReset() {
|
private _handleZoomReset() {
|
||||||
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
|
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
|
||||||
|
this._modifierPressed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleWheel(e: WheelEvent) {
|
private _handleWheel(e: WheelEvent) {
|
||||||
@ -302,10 +536,11 @@ export class HaChartBase extends LitElement {
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
letter-spacing: normal;
|
||||||
}
|
}
|
||||||
.chart-container {
|
.chart-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
max-height: var(--chart-max-height, 400px);
|
max-height: var(--chart-max-height, 350px);
|
||||||
}
|
}
|
||||||
.chart {
|
.chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -321,6 +556,9 @@ export class HaChartBase extends LitElement {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
border: 1px solid var(--divider-color);
|
border: 1px solid var(--divider-color);
|
||||||
}
|
}
|
||||||
|
.has-legend .zoom-reset {
|
||||||
|
top: 64px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,7 +4,6 @@ import { property, state } from "lit/decorators";
|
|||||||
import type { VisualMapComponentOption } from "echarts/components";
|
import type { VisualMapComponentOption } from "echarts/components";
|
||||||
import type { LineSeriesOption } from "echarts/charts";
|
import type { LineSeriesOption } from "echarts/charts";
|
||||||
import type { YAXisOption } from "echarts/types/dist/shared";
|
import type { YAXisOption } from "echarts/types/dist/shared";
|
||||||
import { differenceInDays } from "date-fns";
|
|
||||||
import { styleMap } from "lit/directives/style-map";
|
import { styleMap } from "lit/directives/style-map";
|
||||||
import { getGraphColorByIndex } from "../../common/color/colors";
|
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||||
import { computeRTL } from "../../common/util/compute_rtl";
|
import { computeRTL } from "../../common/util/compute_rtl";
|
||||||
@ -18,10 +17,10 @@ import {
|
|||||||
getNumberFormatOptions,
|
getNumberFormatOptions,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
} from "../../common/number/format_number";
|
} from "../../common/number/format_number";
|
||||||
import { getTimeAxisLabelConfig } from "./axis-label";
|
|
||||||
import { measureTextWidth } from "../../util/text";
|
import { measureTextWidth } from "../../util/text";
|
||||||
import { fireEvent } from "../../common/dom/fire_event";
|
import { fireEvent } from "../../common/dom/fire_event";
|
||||||
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
|
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
|
||||||
|
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||||
|
|
||||||
const safeParseFloat = (value) => {
|
const safeParseFloat = (value) => {
|
||||||
const parsed = parseFloat(value);
|
const parsed = parseFloat(value);
|
||||||
@ -72,6 +71,8 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
|
|
||||||
@state() private _chartOptions?: ECOption;
|
@state() private _chartOptions?: ECOption;
|
||||||
|
|
||||||
|
private _hiddenStats = new Set<string>();
|
||||||
|
|
||||||
@state() private _yWidth = 25;
|
@state() private _yWidth = 25;
|
||||||
|
|
||||||
private _chartTime: Date = new Date();
|
private _chartTime: Date = new Date();
|
||||||
@ -84,49 +85,104 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
.options=${this._chartOptions}
|
.options=${this._chartOptions}
|
||||||
.height=${this.height}
|
.height=${this.height}
|
||||||
style=${styleMap({ height: this.height })}
|
style=${styleMap({ height: this.height })}
|
||||||
|
external-hidden
|
||||||
|
@dataset-hidden=${this._datasetHidden}
|
||||||
|
@dataset-unhidden=${this._datasetUnhidden}
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _renderTooltip(params) {
|
private _renderTooltip(params: any) {
|
||||||
return params
|
const time = params[0].axisValue;
|
||||||
.map((param, index: number) => {
|
const title =
|
||||||
let value = `${formatNumber(
|
formatDateTimeWithSeconds(
|
||||||
param.value[1] as number,
|
new Date(time),
|
||||||
this.hass.locale,
|
this.hass.locale,
|
||||||
getNumberFormatOptions(
|
this.hass.config
|
||||||
undefined,
|
) + "<br>";
|
||||||
this.hass.entities[this._entityIds[param.seriesIndex]]
|
const datapoints: Record<string, any>[] = [];
|
||||||
)
|
this._chartData.forEach((dataset, index) => {
|
||||||
)} ${this.unit}`;
|
if (
|
||||||
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
|
dataset.tooltip?.show === false ||
|
||||||
const data = this.data[dataIndex];
|
this._hiddenStats.has(dataset.name as string)
|
||||||
if (data.statistics && data.statistics.length > 0) {
|
)
|
||||||
value += "<br> ";
|
return;
|
||||||
const source =
|
const param = params.find(
|
||||||
data.states.length === 0 ||
|
(p: Record<string, any>) => p.seriesIndex === index
|
||||||
param.value[0] < data.states[0].last_changed
|
);
|
||||||
? `${this.hass.localize(
|
if (param) {
|
||||||
"ui.components.history_charts.source_stats"
|
datapoints.push(param);
|
||||||
)}`
|
return;
|
||||||
: `${this.hass.localize(
|
}
|
||||||
"ui.components.history_charts.source_history"
|
// If the datapoint is not found, we need to find the last datapoint before the current time
|
||||||
)}`;
|
let lastData;
|
||||||
value += source;
|
const data = dataset.data || [];
|
||||||
|
for (let i = data.length - 1; i >= 0; i--) {
|
||||||
|
const point = data[i];
|
||||||
|
if (point && point[0] <= time && point[1]) {
|
||||||
|
lastData = point;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!lastData) return;
|
||||||
|
datapoints.push({
|
||||||
|
seriesName: dataset.name,
|
||||||
|
seriesIndex: index,
|
||||||
|
value: lastData,
|
||||||
|
// HTML copied from echarts. May change based on options
|
||||||
|
marker: `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const unit = this.unit
|
||||||
|
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
|
||||||
|
: "";
|
||||||
|
|
||||||
const time =
|
return (
|
||||||
index === 0
|
title +
|
||||||
? formatDateTimeWithSeconds(
|
datapoints
|
||||||
new Date(param.value[0]),
|
.map((param) => {
|
||||||
|
const entityId = this._entityIds[param.seriesIndex];
|
||||||
|
const stateObj = this.hass.states[entityId];
|
||||||
|
const entry = this.hass.entities[entityId];
|
||||||
|
const stateValue = String(param.value[1]);
|
||||||
|
let value = stateObj
|
||||||
|
? this.hass.formatEntityState(stateObj, stateValue)
|
||||||
|
: `${formatNumber(
|
||||||
|
stateValue,
|
||||||
this.hass.locale,
|
this.hass.locale,
|
||||||
this.hass.config
|
getNumberFormatOptions(undefined, entry)
|
||||||
) + "<br>"
|
)}${unit}`;
|
||||||
: "";
|
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
|
||||||
return `${time}${param.marker} ${param.seriesName}: ${value}
|
const data = this.data[dataIndex];
|
||||||
`;
|
if (data.statistics && data.statistics.length > 0) {
|
||||||
})
|
value += "<br> ";
|
||||||
.join("<br>");
|
const source =
|
||||||
|
data.states.length === 0 ||
|
||||||
|
param.value[0] < data.states[0].last_changed
|
||||||
|
? `${this.hass.localize(
|
||||||
|
"ui.components.history_charts.source_stats"
|
||||||
|
)}`
|
||||||
|
: `${this.hass.localize(
|
||||||
|
"ui.components.history_charts.source_history"
|
||||||
|
)}`;
|
||||||
|
value += source;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (param.seriesName) {
|
||||||
|
return `${param.marker} ${param.seriesName}: ${value}`;
|
||||||
|
}
|
||||||
|
return `${param.marker} ${value}`;
|
||||||
|
})
|
||||||
|
.join("<br>")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _datasetHidden(ev: CustomEvent) {
|
||||||
|
this._hiddenStats.add(ev.detail.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _datasetUnhidden(ev: CustomEvent) {
|
||||||
|
this._hiddenStats.delete(ev.detail.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public willUpdate(changedProps: PropertyValues) {
|
public willUpdate(changedProps: PropertyValues) {
|
||||||
@ -156,49 +212,44 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
changedProps.has("paddingYAxis") ||
|
changedProps.has("paddingYAxis") ||
|
||||||
changedProps.has("_yWidth")
|
changedProps.has("_yWidth")
|
||||||
) {
|
) {
|
||||||
const dayDifference = differenceInDays(this.endTime, this.startTime);
|
|
||||||
const rtl = computeRTL(this.hass);
|
const rtl = computeRTL(this.hass);
|
||||||
const splitLineStyle = this.hass.themes?.darkMode
|
let minYAxis: number | ((values: { min: number }) => number) | undefined =
|
||||||
? { opacity: 0.15 }
|
this.minYAxis;
|
||||||
: {};
|
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
|
||||||
|
this.maxYAxis;
|
||||||
|
if (typeof minYAxis === "number") {
|
||||||
|
if (this.fitYData) {
|
||||||
|
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
|
||||||
|
}
|
||||||
|
} else if (this.logarithmicScale) {
|
||||||
|
minYAxis = ({ min }) => (min > 0 ? min * 0.95 : min * 1.05);
|
||||||
|
}
|
||||||
|
if (typeof maxYAxis === "number") {
|
||||||
|
if (this.fitYData) {
|
||||||
|
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
|
||||||
|
}
|
||||||
|
} else if (this.logarithmicScale) {
|
||||||
|
maxYAxis = ({ max }) => (max > 0 ? max * 1.05 : max * 0.95);
|
||||||
|
}
|
||||||
this._chartOptions = {
|
this._chartOptions = {
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: "time",
|
type: "time",
|
||||||
min: this.startTime,
|
min: this.startTime,
|
||||||
max: this.endTime,
|
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: {
|
yAxis: {
|
||||||
type: this.logarithmicScale ? "log" : "value",
|
type: this.logarithmicScale ? "log" : "value",
|
||||||
name: this.unit,
|
name: this.unit,
|
||||||
min: this.fitYData ? this.minYAxis : undefined,
|
min: this._clampYAxis(minYAxis),
|
||||||
max: this.fitYData ? this.maxYAxis : undefined,
|
max: this._clampYAxis(maxYAxis),
|
||||||
position: rtl ? "right" : "left",
|
position: rtl ? "right" : "left",
|
||||||
scale: true,
|
scale: true,
|
||||||
nameGap: 2,
|
nameGap: 2,
|
||||||
nameTextStyle: {
|
nameTextStyle: {
|
||||||
align: "left",
|
align: "left",
|
||||||
},
|
},
|
||||||
splitLine: {
|
axisLine: {
|
||||||
show: true,
|
show: false,
|
||||||
lineStyle: splitLineStyle,
|
|
||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
margin: 5,
|
margin: 5,
|
||||||
@ -218,6 +269,8 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
} as YAXisOption,
|
} as YAXisOption,
|
||||||
legend: {
|
legend: {
|
||||||
show: this.showNames,
|
show: this.showNames,
|
||||||
|
type: "scroll",
|
||||||
|
animationDurationUpdate: 400,
|
||||||
icon: "circle",
|
icon: "circle",
|
||||||
padding: [20, 0],
|
padding: [20, 0],
|
||||||
},
|
},
|
||||||
@ -307,13 +360,18 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
prevValues = datavalues;
|
prevValues = datavalues;
|
||||||
};
|
};
|
||||||
|
|
||||||
const addDataSet = (nameY: string, color?: string, fill = false) => {
|
const addDataSet = (
|
||||||
|
id: string,
|
||||||
|
nameY: string,
|
||||||
|
color?: string,
|
||||||
|
fill = false
|
||||||
|
) => {
|
||||||
if (!color) {
|
if (!color) {
|
||||||
color = getGraphColorByIndex(colorIndex, computedStyles);
|
color = getGraphColorByIndex(colorIndex, computedStyles);
|
||||||
colorIndex++;
|
colorIndex++;
|
||||||
}
|
}
|
||||||
data.push({
|
data.push({
|
||||||
id: nameY,
|
id,
|
||||||
data: [],
|
data: [],
|
||||||
type: "line",
|
type: "line",
|
||||||
cursor: "default",
|
cursor: "default",
|
||||||
@ -321,6 +379,7 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
color,
|
color,
|
||||||
symbol: "circle",
|
symbol: "circle",
|
||||||
step: "end",
|
step: "end",
|
||||||
|
animationDurationUpdate: 0,
|
||||||
symbolSize: 1,
|
symbolSize: 1,
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
width: fill ? 0 : 1.5,
|
width: fill ? 0 : 1.5,
|
||||||
@ -375,13 +434,23 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
entityState.attributes.target_temp_low
|
entityState.attributes.target_temp_low
|
||||||
);
|
);
|
||||||
addDataSet(
|
addDataSet(
|
||||||
`${this.hass.localize("ui.card.climate.current_temperature", {
|
states.entity_id + "-current_temperature",
|
||||||
name: name,
|
this.showNames
|
||||||
})}`
|
? this.hass.localize("ui.card.climate.current_temperature", {
|
||||||
|
name: name,
|
||||||
|
})
|
||||||
|
: this.hass.localize(
|
||||||
|
"component.climate.entity_component._.state_attributes.current_temperature.name"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
if (hasHeat) {
|
if (hasHeat) {
|
||||||
addDataSet(
|
addDataSet(
|
||||||
`${this.hass.localize("ui.card.climate.heating", { name: name })}`,
|
states.entity_id + "-heating",
|
||||||
|
this.showNames
|
||||||
|
? this.hass.localize("ui.card.climate.heating", { name: name })
|
||||||
|
: this.hass.localize(
|
||||||
|
"component.climate.entity_component._.state_attributes.hvac_action.state.heating"
|
||||||
|
),
|
||||||
computedStyles.getPropertyValue("--state-climate-heat-color"),
|
computedStyles.getPropertyValue("--state-climate-heat-color"),
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
@ -390,7 +459,12 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
}
|
}
|
||||||
if (hasCool) {
|
if (hasCool) {
|
||||||
addDataSet(
|
addDataSet(
|
||||||
`${this.hass.localize("ui.card.climate.cooling", { name: name })}`,
|
states.entity_id + "-cooling",
|
||||||
|
this.showNames
|
||||||
|
? this.hass.localize("ui.card.climate.cooling", { name: name })
|
||||||
|
: this.hass.localize(
|
||||||
|
"component.climate.entity_component._.state_attributes.hvac_action.state.cooling"
|
||||||
|
),
|
||||||
computedStyles.getPropertyValue("--state-climate-cool-color"),
|
computedStyles.getPropertyValue("--state-climate-cool-color"),
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
@ -400,22 +474,40 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
|
|
||||||
if (hasTargetRange) {
|
if (hasTargetRange) {
|
||||||
addDataSet(
|
addDataSet(
|
||||||
`${this.hass.localize("ui.card.climate.target_temperature_mode", {
|
states.entity_id + "-target_temperature_mode",
|
||||||
name: name,
|
this.showNames
|
||||||
mode: this.hass.localize("ui.card.climate.high"),
|
? this.hass.localize("ui.card.climate.target_temperature_mode", {
|
||||||
})}`
|
name: name,
|
||||||
|
mode: this.hass.localize("ui.card.climate.high"),
|
||||||
|
})
|
||||||
|
: this.hass.localize(
|
||||||
|
"component.climate.entity_component._.state_attributes.target_temp_high.name"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
addDataSet(
|
addDataSet(
|
||||||
`${this.hass.localize("ui.card.climate.target_temperature_mode", {
|
states.entity_id + "-target_temperature_mode_low",
|
||||||
name: name,
|
this.showNames
|
||||||
mode: this.hass.localize("ui.card.climate.low"),
|
? this.hass.localize("ui.card.climate.target_temperature_mode", {
|
||||||
})}`
|
name: name,
|
||||||
|
mode: this.hass.localize("ui.card.climate.low"),
|
||||||
|
})
|
||||||
|
: this.hass.localize(
|
||||||
|
"component.climate.entity_component._.state_attributes.target_temp_low.name"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
addDataSet(
|
addDataSet(
|
||||||
`${this.hass.localize("ui.card.climate.target_temperature_entity", {
|
states.entity_id + "-target_temperature",
|
||||||
name: name,
|
this.showNames
|
||||||
})}`
|
? this.hass.localize(
|
||||||
|
"ui.card.climate.target_temperature_entity",
|
||||||
|
{
|
||||||
|
name: name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: this.hass.localize(
|
||||||
|
"component.climate.entity_component._.state_attributes.temperature.name"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -468,19 +560,29 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
);
|
);
|
||||||
|
|
||||||
addDataSet(
|
addDataSet(
|
||||||
`${this.hass.localize("ui.card.humidifier.target_humidity_entity", {
|
states.entity_id + "-target_humidity",
|
||||||
name: name,
|
this.showNames
|
||||||
})}`
|
? this.hass.localize("ui.card.humidifier.target_humidity_entity", {
|
||||||
|
name: name,
|
||||||
|
})
|
||||||
|
: this.hass.localize(
|
||||||
|
"component.humidifier.entity_component._.state_attributes.humidity.name"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasCurrent) {
|
if (hasCurrent) {
|
||||||
addDataSet(
|
addDataSet(
|
||||||
`${this.hass.localize(
|
states.entity_id + "-current_humidity",
|
||||||
"ui.card.humidifier.current_humidity_entity",
|
this.showNames
|
||||||
{
|
? this.hass.localize(
|
||||||
name: name,
|
"ui.card.humidifier.current_humidity_entity",
|
||||||
}
|
{
|
||||||
)}`
|
name: name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: this.hass.localize(
|
||||||
|
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -488,25 +590,40 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
// If action attribute is not available, we shade the area when the device is on
|
// If action attribute is not available, we shade the area when the device is on
|
||||||
if (hasHumidifying) {
|
if (hasHumidifying) {
|
||||||
addDataSet(
|
addDataSet(
|
||||||
`${this.hass.localize("ui.card.humidifier.humidifying", {
|
states.entity_id + "-humidifying",
|
||||||
name: name,
|
this.showNames
|
||||||
})}`,
|
? this.hass.localize("ui.card.humidifier.humidifying", {
|
||||||
|
name: name,
|
||||||
|
})
|
||||||
|
: this.hass.localize(
|
||||||
|
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
|
||||||
|
),
|
||||||
computedStyles.getPropertyValue("--state-humidifier-on-color"),
|
computedStyles.getPropertyValue("--state-humidifier-on-color"),
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
} else if (hasDrying) {
|
} else if (hasDrying) {
|
||||||
addDataSet(
|
addDataSet(
|
||||||
`${this.hass.localize("ui.card.humidifier.drying", {
|
states.entity_id + "-drying",
|
||||||
name: name,
|
this.showNames
|
||||||
})}`,
|
? this.hass.localize("ui.card.humidifier.drying", {
|
||||||
|
name: name,
|
||||||
|
})
|
||||||
|
: this.hass.localize(
|
||||||
|
"component.humidifier.entity_component._.state_attributes.action.state.drying"
|
||||||
|
),
|
||||||
computedStyles.getPropertyValue("--state-humidifier-on-color"),
|
computedStyles.getPropertyValue("--state-humidifier-on-color"),
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
addDataSet(
|
addDataSet(
|
||||||
`${this.hass.localize("ui.card.humidifier.on_entity", {
|
states.entity_id + "-on",
|
||||||
name: name,
|
this.showNames
|
||||||
})}`,
|
? this.hass.localize("ui.card.humidifier.on_entity", {
|
||||||
|
name: name,
|
||||||
|
})
|
||||||
|
: this.hass.localize(
|
||||||
|
"component.humidifier.entity_component._.state.on"
|
||||||
|
),
|
||||||
undefined,
|
undefined,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
@ -539,7 +656,7 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
pushData(new Date(entityState.last_changed), series);
|
pushData(new Date(entityState.last_changed), series);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
addDataSet(name);
|
addDataSet(states.entity_id, name);
|
||||||
|
|
||||||
let lastValue: number;
|
let lastValue: number;
|
||||||
let lastDate: Date;
|
let lastDate: Date;
|
||||||
@ -609,6 +726,19 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
this._entityIds = entityIds;
|
this._entityIds = entityIds;
|
||||||
this._datasetToDataIndex = datasetToDataIndex;
|
this._datasetToDataIndex = datasetToDataIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _clampYAxis(value?: number | ((values: any) => number)) {
|
||||||
|
if (this.logarithmicScale) {
|
||||||
|
// log(0) is -Infinity, so we need to set a minimum value
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Math.max(value, 0.1);
|
||||||
|
}
|
||||||
|
if (typeof value === "function") {
|
||||||
|
return (values: any) => Math.max(value(values), 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
customElements.define("state-history-chart-line", StateHistoryChartLine);
|
customElements.define("state-history-chart-line", StateHistoryChartLine);
|
||||||
|
|
||||||
|
@ -8,7 +8,6 @@ import type {
|
|||||||
TooltipFormatterCallback,
|
TooltipFormatterCallback,
|
||||||
TooltipPositionCallbackParams,
|
TooltipPositionCallbackParams,
|
||||||
} from "echarts/types/dist/shared";
|
} from "echarts/types/dist/shared";
|
||||||
import { differenceInDays } from "date-fns";
|
|
||||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||||
import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration";
|
import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration";
|
||||||
import { computeRTL } from "../../common/util/compute_rtl";
|
import { computeRTL } from "../../common/util/compute_rtl";
|
||||||
@ -22,7 +21,6 @@ import { luminosity } from "../../common/color/rgb";
|
|||||||
import { hex2rgb } from "../../common/color/convert-color";
|
import { hex2rgb } from "../../common/color/convert-color";
|
||||||
import { measureTextWidth } from "../../util/text";
|
import { measureTextWidth } from "../../util/text";
|
||||||
import { fireEvent } from "../../common/dom/fire_event";
|
import { fireEvent } from "../../common/dom/fire_event";
|
||||||
import { getTimeAxisLabelConfig } from "./axis-label";
|
|
||||||
|
|
||||||
@customElement("state-history-chart-timeline")
|
@customElement("state-history-chart-timeline")
|
||||||
export class StateHistoryChartTimeline extends LitElement {
|
export class StateHistoryChartTimeline extends LitElement {
|
||||||
@ -67,7 +65,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.options=${this._chartOptions}
|
.options=${this._chartOptions}
|
||||||
.height=${`${this.data.length * 30 + 30}px`}
|
.height=${`${this.data.length * 30 + 30}px`}
|
||||||
.data=${this._chartData}
|
.data=${this._chartData as ECOption["series"]}
|
||||||
@chart-click=${this._handleChartClick}
|
@chart-click=${this._handleChartClick}
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
`;
|
`;
|
||||||
@ -129,10 +127,12 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
|
|
||||||
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
|
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
|
||||||
(params: TooltipPositionCallbackParams) => {
|
(params: TooltipPositionCallbackParams) => {
|
||||||
const { value, name, marker } = Array.isArray(params)
|
const { value, name, marker, seriesName } = Array.isArray(params)
|
||||||
? params[0]
|
? params[0]
|
||||||
: params;
|
: params;
|
||||||
const title = `<h4 style="text-align: center; margin: 0;">${value![0]}</h4>`;
|
const title = seriesName
|
||||||
|
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
|
||||||
|
: "";
|
||||||
const durationInMs = value![2] - value![1];
|
const durationInMs = value![2] - value![1];
|
||||||
const formattedDuration = `${this.hass.localize(
|
const formattedDuration = `${this.hass.localize(
|
||||||
"ui.components.history_charts.duration"
|
"ui.components.history_charts.duration"
|
||||||
@ -183,13 +183,12 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
private _createOptions() {
|
private _createOptions() {
|
||||||
const narrow = this.narrow;
|
const narrow = this.narrow;
|
||||||
const showNames = this.chunked || this.showNames;
|
const showNames = this.chunked || this.showNames;
|
||||||
const maxInternalLabelWidth = narrow ? 70 : 165;
|
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||||
const labelWidth = showNames
|
const labelWidth = showNames
|
||||||
? Math.max(this.paddingYAxis, this._yWidth)
|
? Math.max(this.paddingYAxis, this._yWidth)
|
||||||
: 0;
|
: 0;
|
||||||
const labelMargin = 5;
|
const labelMargin = 5;
|
||||||
const rtl = computeRTL(this.hass);
|
const rtl = computeRTL(this.hass);
|
||||||
const dayDifference = differenceInDays(this.endTime, this.startTime);
|
|
||||||
this._chartOptions = {
|
this._chartOptions = {
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: "time",
|
type: "time",
|
||||||
@ -197,21 +196,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
max: this.endTime,
|
max: this.endTime,
|
||||||
axisTick: {
|
axisTick: {
|
||||||
show: true,
|
show: true,
|
||||||
lineStyle: {
|
|
||||||
opacity: 0.4,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
axisLabel: getTimeAxisLabelConfig(
|
splitLine: {
|
||||||
this.hass.locale,
|
show: false,
|
||||||
this.hass.config,
|
},
|
||||||
dayDifference
|
|
||||||
),
|
|
||||||
minInterval:
|
|
||||||
dayDifference >= 89 // quarter
|
|
||||||
? 28 * 3600 * 24 * 1000
|
|
||||||
: dayDifference > 2
|
|
||||||
? 3600 * 24 * 1000
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: "category",
|
type: "category",
|
||||||
@ -226,14 +214,18 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
show: showNames,
|
show: showNames,
|
||||||
width: labelWidth - labelMargin,
|
width: labelWidth,
|
||||||
overflow: "truncate",
|
overflow: "truncate",
|
||||||
margin: labelMargin,
|
margin: labelMargin,
|
||||||
formatter: (label: string) => {
|
formatter: (id: string) => {
|
||||||
const width = Math.min(
|
const label = this._chartData.find((d) => d.id === id)
|
||||||
measureTextWidth(label, 12) + labelMargin,
|
?.name as string;
|
||||||
maxInternalLabelWidth
|
const width = label
|
||||||
);
|
? Math.min(
|
||||||
|
measureTextWidth(label, 12) + labelMargin,
|
||||||
|
maxInternalLabelWidth
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
if (width > this._yWidth) {
|
if (width > this._yWidth) {
|
||||||
this._yWidth = width;
|
this._yWidth = width;
|
||||||
fireEvent(this, "y-width-changed", {
|
fireEvent(this, "y-width-changed", {
|
||||||
@ -278,8 +270,9 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
let prevState: string | null = null;
|
let prevState: string | null = null;
|
||||||
let locState: string | null = null;
|
let locState: string | null = null;
|
||||||
let prevLastChanged = startTime;
|
let prevLastChanged = startTime;
|
||||||
const entityDisplay: string =
|
const entityDisplay: string = this.showNames
|
||||||
names[stateInfo.entity_id] || stateInfo.name;
|
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
|
||||||
|
: "";
|
||||||
|
|
||||||
const dataRow: unknown[] = [];
|
const dataRow: unknown[] = [];
|
||||||
stateInfo.data.forEach((entityState) => {
|
stateInfo.data.forEach((entityState) => {
|
||||||
@ -307,7 +300,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
);
|
);
|
||||||
dataRow.push({
|
dataRow.push({
|
||||||
value: [
|
value: [
|
||||||
entityDisplay,
|
stateInfo.entity_id,
|
||||||
prevLastChanged,
|
prevLastChanged,
|
||||||
newLastChanged,
|
newLastChanged,
|
||||||
locState,
|
locState,
|
||||||
@ -333,7 +326,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
);
|
);
|
||||||
dataRow.push({
|
dataRow.push({
|
||||||
value: [
|
value: [
|
||||||
entityDisplay,
|
stateInfo.entity_id,
|
||||||
prevLastChanged,
|
prevLastChanged,
|
||||||
endTime,
|
endTime,
|
||||||
locState,
|
locState,
|
||||||
@ -346,9 +339,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
datasets.push({
|
datasets.push({
|
||||||
|
id: stateInfo.entity_id,
|
||||||
data: dataRow,
|
data: dataRow,
|
||||||
name: entityDisplay,
|
name: entityDisplay,
|
||||||
dimensions: ["index", "start", "end", "name", "color", "textColor"],
|
dimensions: ["id", "start", "end", "name", "color", "textColor"],
|
||||||
type: "custom",
|
type: "custom",
|
||||||
encode: {
|
encode: {
|
||||||
x: [1, 2],
|
x: [1, 2],
|
||||||
@ -364,10 +358,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
|
|
||||||
private _handleChartClick(e: CustomEvent<ECElementEvent>): void {
|
private _handleChartClick(e: CustomEvent<ECElementEvent>): void {
|
||||||
if (e.detail.targetType === "axisLabel") {
|
if (e.detail.targetType === "axisLabel") {
|
||||||
const dataset = this.data[e.detail.dataIndex];
|
const dataset = this._chartData[e.detail.dataIndex];
|
||||||
if (dataset) {
|
if (dataset) {
|
||||||
fireEvent(this, "hass-more-info", {
|
fireEvent(this, "hass-more-info", {
|
||||||
entityId: dataset.entity_id,
|
entityId: dataset.id as string,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -135,7 +135,7 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
return html``;
|
return html``;
|
||||||
}
|
}
|
||||||
if (!Array.isArray(item)) {
|
if (!Array.isArray(item)) {
|
||||||
return html`<div class="entry-container">
|
return html`<div class="entry-container line">
|
||||||
<state-history-chart-line
|
<state-history-chart-line
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.unit=${item.unit}
|
.unit=${item.unit}
|
||||||
@ -157,7 +157,7 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
></state-history-chart-line>
|
></state-history-chart-line>
|
||||||
</div> `;
|
</div> `;
|
||||||
}
|
}
|
||||||
return html`<div class="entry-container">
|
return html`<div class="entry-container timeline">
|
||||||
<state-history-chart-timeline
|
<state-history-chart-timeline
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.data=${item}
|
.data=${item}
|
||||||
@ -299,6 +299,9 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
|
|
||||||
.entry-container {
|
.entry-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-container.line {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -313,6 +316,10 @@ export class StateHistoryCharts extends LitElement {
|
|||||||
padding-inline-end: 1px;
|
padding-inline-end: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-container.timeline:first-child {
|
||||||
|
margin-top: var(--timeline-top-margin);
|
||||||
|
}
|
||||||
|
|
||||||
.entry-container:not(:first-child) {
|
.entry-container:not(:first-child) {
|
||||||
border-top: 2px solid var(--divider-color);
|
border-top: 2px solid var(--divider-color);
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
|
@ -1,15 +1,22 @@
|
|||||||
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 {
|
import type {
|
||||||
BarSeriesOption,
|
BarSeriesOption,
|
||||||
LineSeriesOption,
|
LineSeriesOption,
|
||||||
} from "echarts/types/dist/shared";
|
} from "echarts/types/dist/shared";
|
||||||
|
import type { PropertyValues, TemplateResult } from "lit";
|
||||||
|
import { css, html, LitElement } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { styleMap } from "lit/directives/style-map";
|
import { styleMap } from "lit/directives/style-map";
|
||||||
|
import memoizeOne from "memoize-one";
|
||||||
import { getGraphColorByIndex } from "../../common/color/colors";
|
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||||
|
|
||||||
|
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||||
|
import {
|
||||||
|
formatNumber,
|
||||||
|
getNumberFormatOptions,
|
||||||
|
} from "../../common/number/format_number";
|
||||||
|
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||||
|
import { computeRTL } from "../../common/util/compute_rtl";
|
||||||
import type {
|
import type {
|
||||||
Statistics,
|
Statistics,
|
||||||
StatisticsMetaData,
|
StatisticsMetaData,
|
||||||
@ -21,16 +28,9 @@ import {
|
|||||||
getStatisticMetadata,
|
getStatisticMetadata,
|
||||||
statisticsHaveType,
|
statisticsHaveType,
|
||||||
} from "../../data/recorder";
|
} from "../../data/recorder";
|
||||||
|
import type { ECOption } from "../../resources/echarts";
|
||||||
import type { HomeAssistant } from "../../types";
|
import type { HomeAssistant } from "../../types";
|
||||||
import "./ha-chart-base";
|
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";
|
|
||||||
|
|
||||||
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||||
mean: "mean",
|
mean: "mean",
|
||||||
@ -56,6 +56,8 @@ export class StatisticsChart extends LitElement {
|
|||||||
|
|
||||||
@property() public unit?: string;
|
@property() public unit?: string;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public startTime?: Date;
|
||||||
|
|
||||||
@property({ attribute: false }) public endTime?: Date;
|
@property({ attribute: false }) public endTime?: Date;
|
||||||
|
|
||||||
@property({ attribute: false, type: Array })
|
@property({ attribute: false, type: Array })
|
||||||
@ -124,7 +126,10 @@ export class StatisticsChart extends LitElement {
|
|||||||
changedProps.has("fitYData") ||
|
changedProps.has("fitYData") ||
|
||||||
changedProps.has("logarithmicScale") ||
|
changedProps.has("logarithmicScale") ||
|
||||||
changedProps.has("hideLegend") ||
|
changedProps.has("hideLegend") ||
|
||||||
changedProps.has("_legendData")
|
changedProps.has("startTime") ||
|
||||||
|
changedProps.has("endTime") ||
|
||||||
|
changedProps.has("_legendData") ||
|
||||||
|
changedProps.has("_chartData")
|
||||||
) {
|
) {
|
||||||
this._createOptions();
|
this._createOptions();
|
||||||
}
|
}
|
||||||
@ -181,18 +186,31 @@ export class StatisticsChart extends LitElement {
|
|||||||
this.requestUpdate("_hiddenStats");
|
this.requestUpdate("_hiddenStats");
|
||||||
}
|
}
|
||||||
|
|
||||||
private _renderTooltip = (params: any) =>
|
private _renderTooltip = (params: any) => {
|
||||||
params
|
const rendered: Record<string, boolean> = {};
|
||||||
|
const unit = this.unit
|
||||||
|
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
|
||||||
|
: "";
|
||||||
|
return params
|
||||||
.map((param, index: number) => {
|
.map((param, index: number) => {
|
||||||
|
if (rendered[param.seriesName]) return "";
|
||||||
|
rendered[param.seriesName] = true;
|
||||||
|
|
||||||
|
const statisticId = this._statisticIds[param.seriesIndex];
|
||||||
|
const stateObj = this.hass.states[statisticId];
|
||||||
|
const entry = this.hass.entities[statisticId];
|
||||||
|
// max series can have 3 values, as the second value is the max-min to form a band
|
||||||
|
const rawValue = String(param.value[2] ?? param.value[1]);
|
||||||
|
|
||||||
|
const options = getNumberFormatOptions(stateObj, entry) ?? {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
};
|
||||||
|
|
||||||
const value = `${formatNumber(
|
const value = `${formatNumber(
|
||||||
// max series can have 3 values, as the second value is the max-min to form a band
|
rawValue,
|
||||||
(param.value[2] ?? param.value[1]) as number,
|
|
||||||
this.hass.locale,
|
this.hass.locale,
|
||||||
getNumberFormatOptions(
|
options
|
||||||
undefined,
|
)}${unit}`;
|
||||||
this.hass.entities[this._statisticIds[param.seriesIndex]]
|
|
||||||
)
|
|
||||||
)} ${this.unit}`;
|
|
||||||
|
|
||||||
const time =
|
const time =
|
||||||
index === 0
|
index === 0
|
||||||
@ -202,36 +220,68 @@ export class StatisticsChart extends LitElement {
|
|||||||
this.hass.config
|
this.hass.config
|
||||||
) + "<br>"
|
) + "<br>"
|
||||||
: "";
|
: "";
|
||||||
return `${time}${param.marker} ${param.seriesName}: ${value}
|
return `${time}${param.marker} ${param.seriesName}: ${value}`;
|
||||||
`;
|
|
||||||
})
|
})
|
||||||
|
.filter(Boolean)
|
||||||
.join("<br>");
|
.join("<br>");
|
||||||
|
};
|
||||||
|
|
||||||
private _createOptions() {
|
private _createOptions() {
|
||||||
const splitLineStyle = this.hass.themes?.darkMode ? { opacity: 0.15 } : {};
|
|
||||||
const dayDifference = this.daysToShow ?? 1;
|
const dayDifference = this.daysToShow ?? 1;
|
||||||
|
let minYAxis: number | ((values: { min: number }) => number) | undefined =
|
||||||
|
this.minYAxis;
|
||||||
|
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
|
||||||
|
this.maxYAxis;
|
||||||
|
if (typeof minYAxis === "number") {
|
||||||
|
if (this.fitYData) {
|
||||||
|
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
|
||||||
|
}
|
||||||
|
} else if (this.logarithmicScale) {
|
||||||
|
minYAxis = ({ min }) => (min > 0 ? min * 0.95 : min * 1.05);
|
||||||
|
}
|
||||||
|
if (typeof maxYAxis === "number") {
|
||||||
|
if (this.fitYData) {
|
||||||
|
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
|
||||||
|
}
|
||||||
|
} else if (this.logarithmicScale) {
|
||||||
|
maxYAxis = ({ max }) => (max > 0 ? max * 1.05 : max * 0.95);
|
||||||
|
}
|
||||||
|
const endTime = this.endTime ?? new Date();
|
||||||
|
let startTime = this.startTime;
|
||||||
|
|
||||||
|
if (!startTime) {
|
||||||
|
// set start time to the earliest point in the chart data
|
||||||
|
this._chartData.forEach((series) => {
|
||||||
|
if (!Array.isArray(series.data) || !series.data[0]) return;
|
||||||
|
const firstPoint = series.data[0] as any;
|
||||||
|
const timestamp = Array.isArray(firstPoint)
|
||||||
|
? firstPoint[0]
|
||||||
|
: firstPoint.value?.[0];
|
||||||
|
if (timestamp && (!startTime || new Date(timestamp) < startTime)) {
|
||||||
|
startTime = new Date(timestamp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!startTime) {
|
||||||
|
// Calculate default start time based on dayDifference
|
||||||
|
startTime = new Date(
|
||||||
|
endTime.getTime() - dayDifference * 24 * 3600 * 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this._chartOptions = {
|
this._chartOptions = {
|
||||||
xAxis: {
|
xAxis: [
|
||||||
type: "time",
|
{
|
||||||
axisLabel: getTimeAxisLabelConfig(
|
type: "time",
|
||||||
this.hass.locale,
|
min: startTime,
|
||||||
this.hass.config,
|
max: endTime,
|
||||||
dayDifference
|
},
|
||||||
),
|
{
|
||||||
axisLine: {
|
type: "time",
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
splitLine: {
|
],
|
||||||
show: true,
|
|
||||||
lineStyle: splitLineStyle,
|
|
||||||
},
|
|
||||||
minInterval:
|
|
||||||
dayDifference >= 89 // quarter
|
|
||||||
? 28 * 3600 * 24 * 1000
|
|
||||||
: dayDifference > 2
|
|
||||||
? 3600 * 24 * 1000
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: this.logarithmicScale ? "log" : "value",
|
type: this.logarithmicScale ? "log" : "value",
|
||||||
name: this.unit,
|
name: this.unit,
|
||||||
@ -241,23 +291,24 @@ export class StatisticsChart extends LitElement {
|
|||||||
},
|
},
|
||||||
position: computeRTL(this.hass) ? "right" : "left",
|
position: computeRTL(this.hass) ? "right" : "left",
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
scale: this.chartType !== "bar",
|
scale: true,
|
||||||
min: this.fitYData ? undefined : this.minYAxis,
|
min: this._clampYAxis(minYAxis),
|
||||||
max: this.fitYData ? undefined : this.maxYAxis,
|
max: this._clampYAxis(maxYAxis),
|
||||||
splitLine: {
|
splitLine: {
|
||||||
show: true,
|
show: true,
|
||||||
lineStyle: splitLineStyle,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
show: !this.hideLegend,
|
show: !this.hideLegend,
|
||||||
|
type: "scroll",
|
||||||
|
animationDurationUpdate: 400,
|
||||||
icon: "circle",
|
icon: "circle",
|
||||||
padding: [20, 0],
|
padding: [20, 0],
|
||||||
data: this._legendData,
|
data: this._legendData,
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
|
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
|
||||||
left: 20,
|
left: 1,
|
||||||
right: 1,
|
right: 1,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
containLabel: true,
|
containLabel: true,
|
||||||
@ -318,6 +369,7 @@ export class StatisticsChart extends LitElement {
|
|||||||
if (endTime > new Date()) {
|
if (endTime > new Date()) {
|
||||||
endTime = new Date();
|
endTime = new Date();
|
||||||
}
|
}
|
||||||
|
this.endTime = endTime;
|
||||||
|
|
||||||
let unit: string | undefined | null;
|
let unit: string | undefined | null;
|
||||||
|
|
||||||
@ -369,10 +421,12 @@ export class StatisticsChart extends LitElement {
|
|||||||
) {
|
) {
|
||||||
// if the end of the previous data doesn't match the start of the current data,
|
// 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.
|
// 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(
|
||||||
|
this._transformDataValue([prevEndTime, ...prevValues[i]!])
|
||||||
|
);
|
||||||
d.data!.push([prevEndTime, null]);
|
d.data!.push([prevEndTime, null]);
|
||||||
}
|
}
|
||||||
d.data!.push([start, ...dataValues[i]!]);
|
d.data!.push(this._transformDataValue([start, ...dataValues[i]!]));
|
||||||
});
|
});
|
||||||
prevValues = dataValues;
|
prevValues = dataValues;
|
||||||
prevEndTime = end;
|
prevEndTime = end;
|
||||||
@ -421,9 +475,14 @@ export class StatisticsChart extends LitElement {
|
|||||||
displayedLegend = displayedLegend || showLegend;
|
displayedLegend = displayedLegend || showLegend;
|
||||||
}
|
}
|
||||||
statTypes.push(type);
|
statTypes.push(type);
|
||||||
|
const borderColor =
|
||||||
|
band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color;
|
||||||
|
const backgroundColor = band ? color + "3F" : color + "7F";
|
||||||
const series: LineSeriesOption | BarSeriesOption = {
|
const series: LineSeriesOption | BarSeriesOption = {
|
||||||
id: `${statistic_id}-${type}`,
|
id: `${statistic_id}-${type}`,
|
||||||
type: this.chartType,
|
type: this.chartType,
|
||||||
|
smooth: this.chartType === "line" ? 0.4 : false,
|
||||||
|
smoothMonotone: "x",
|
||||||
cursor: "default",
|
cursor: "default",
|
||||||
data: [],
|
data: [],
|
||||||
name: name
|
name: name
|
||||||
@ -435,6 +494,7 @@ export class StatisticsChart extends LitElement {
|
|||||||
),
|
),
|
||||||
symbol: "circle",
|
symbol: "circle",
|
||||||
symbolSize: 0,
|
symbolSize: 0,
|
||||||
|
animationDurationUpdate: 0,
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
},
|
},
|
||||||
@ -442,21 +502,16 @@ export class StatisticsChart extends LitElement {
|
|||||||
this.chartType === "bar"
|
this.chartType === "bar"
|
||||||
? {
|
? {
|
||||||
borderRadius: [4, 4, 0, 0],
|
borderRadius: [4, 4, 0, 0],
|
||||||
borderColor:
|
borderColor,
|
||||||
band && hasMean
|
|
||||||
? color + (this.hideLegend ? "00" : "7F")
|
|
||||||
: color,
|
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
color: band ? color + "3F" : color + "7F",
|
color: this.chartType === "bar" ? backgroundColor : borderColor,
|
||||||
};
|
};
|
||||||
if (band && this.chartType === "line") {
|
if (band && this.chartType === "line") {
|
||||||
series.stack = `band-${statistic_id}`;
|
series.stack = `band-${statistic_id}`;
|
||||||
|
series.stackStrategy = "all";
|
||||||
(series as LineSeriesOption).symbol = "none";
|
(series as LineSeriesOption).symbol = "none";
|
||||||
(series as LineSeriesOption).lineStyle = {
|
|
||||||
opacity: 0,
|
|
||||||
};
|
|
||||||
if (drawBands && type === "max") {
|
if (drawBands && type === "max") {
|
||||||
(series as LineSeriesOption).areaStyle = {
|
(series as LineSeriesOption).areaStyle = {
|
||||||
color: color + "3F",
|
color: color + "3F",
|
||||||
@ -489,7 +544,7 @@ export class StatisticsChart extends LitElement {
|
|||||||
}
|
}
|
||||||
} else if (type === "max" && this.chartType === "line") {
|
} else if (type === "max" && this.chartType === "line") {
|
||||||
const max = stat.max || 0;
|
const max = stat.max || 0;
|
||||||
val.push(max - (stat.min || 0));
|
val.push(Math.abs(max - (stat.min || 0)));
|
||||||
val.push(max);
|
val.push(max);
|
||||||
} else {
|
} else {
|
||||||
val.push(stat[type] ?? null);
|
val.push(stat[type] ?? null);
|
||||||
@ -518,6 +573,7 @@ export class StatisticsChart extends LitElement {
|
|||||||
color,
|
color,
|
||||||
type: this.chartType,
|
type: this.chartType,
|
||||||
data: [],
|
data: [],
|
||||||
|
xAxisIndex: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -529,6 +585,26 @@ export class StatisticsChart extends LitElement {
|
|||||||
this._statisticIds = statisticIds;
|
this._statisticIds = statisticIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _transformDataValue(val: [Date, ...(number | null)[]]) {
|
||||||
|
if (this.chartType === "bar" && val[1] && val[1] < 0) {
|
||||||
|
return { value: val, itemStyle: { borderRadius: [0, 0, 4, 4] } };
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _clampYAxis(value?: number | ((values: any) => number)) {
|
||||||
|
if (this.logarithmicScale) {
|
||||||
|
// log(0) is -Infinity, so we need to set a minimum value
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Math.max(value, 0.1);
|
||||||
|
}
|
||||||
|
if (typeof value === "function") {
|
||||||
|
return (values: any) => Math.max(value(values), 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -329,15 +329,12 @@ export class HaBaseTimeInput extends LitElement {
|
|||||||
:host([clearable]) {
|
:host([clearable]) {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.time-input-wrap-wrap {
|
.time-input-wrap-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
.time-input-wrap {
|
.time-input-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: var(--time-input-flex, unset);
|
||||||
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
|
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -9,12 +9,13 @@ import {
|
|||||||
endOfMonth,
|
endOfMonth,
|
||||||
endOfWeek,
|
endOfWeek,
|
||||||
endOfYear,
|
endOfYear,
|
||||||
|
isThisYear,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
startOfYear,
|
startOfYear,
|
||||||
isThisYear,
|
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
|
import { fromZonedTime, toZonedTime } from "date-fns-tz";
|
||||||
import type { PropertyValues, TemplateResult } from "lit";
|
import type { PropertyValues, TemplateResult } from "lit";
|
||||||
import { LitElement, css, html, nothing } from "lit";
|
import { LitElement, css, html, nothing } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
@ -22,16 +23,18 @@ import { ifDefined } from "lit/directives/if-defined";
|
|||||||
import { calcDate, shiftDateRange } from "../common/datetime/calc_date";
|
import { calcDate, shiftDateRange } from "../common/datetime/calc_date";
|
||||||
import { firstWeekdayIndex } from "../common/datetime/first_weekday";
|
import { firstWeekdayIndex } from "../common/datetime/first_weekday";
|
||||||
import {
|
import {
|
||||||
formatShortDateTimeWithYear,
|
|
||||||
formatShortDateTime,
|
formatShortDateTime,
|
||||||
|
formatShortDateTimeWithYear,
|
||||||
} from "../common/datetime/format_date_time";
|
} from "../common/datetime/format_date_time";
|
||||||
import { useAmPm } from "../common/datetime/use_am_pm";
|
import { useAmPm } from "../common/datetime/use_am_pm";
|
||||||
|
import { fireEvent } from "../common/dom/fire_event";
|
||||||
|
import { TimeZone } from "../data/translation";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
import "./date-range-picker";
|
import "./date-range-picker";
|
||||||
import "./ha-icon-button";
|
import "./ha-icon-button";
|
||||||
import "./ha-textarea";
|
|
||||||
import "./ha-icon-button-next";
|
import "./ha-icon-button-next";
|
||||||
import "./ha-icon-button-prev";
|
import "./ha-icon-button-prev";
|
||||||
|
import "./ha-textarea";
|
||||||
|
|
||||||
export type DateRangePickerRanges = Record<string, [Date, Date]>;
|
export type DateRangePickerRanges = Record<string, [Date, Date]>;
|
||||||
|
|
||||||
@ -197,14 +200,15 @@ export class HaDateRangePicker extends LitElement {
|
|||||||
?auto-apply=${this.autoApply}
|
?auto-apply=${this.autoApply}
|
||||||
time-picker=${this.timePicker}
|
time-picker=${this.timePicker}
|
||||||
twentyfour-hours=${this._hour24format}
|
twentyfour-hours=${this._hour24format}
|
||||||
start-date=${this.startDate.toISOString()}
|
start-date=${this._formatDate(this.startDate)}
|
||||||
end-date=${this.endDate.toISOString()}
|
end-date=${this._formatDate(this.endDate)}
|
||||||
?ranges=${this.ranges !== false}
|
?ranges=${this.ranges !== false}
|
||||||
opening-direction=${ifDefined(
|
opening-direction=${ifDefined(
|
||||||
this.openingDirection || this._calcedOpeningDirection
|
this.openingDirection || this._calcedOpeningDirection
|
||||||
)}
|
)}
|
||||||
first-day=${firstWeekdayIndex(this.hass.locale)}
|
first-day=${firstWeekdayIndex(this.hass.locale)}
|
||||||
language=${this.hass.locale.language}
|
language=${this.hass.locale.language}
|
||||||
|
@change=${this._handleChange}
|
||||||
>
|
>
|
||||||
<div slot="input" class="date-range-inputs" @click=${this._handleClick}>
|
<div slot="input" class="date-range-inputs" @click=${this._handleClick}>
|
||||||
${!this.minimal
|
${!this.minimal
|
||||||
@ -325,9 +329,31 @@ export class HaDateRangePicker extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _applyDateRange() {
|
private _applyDateRange() {
|
||||||
|
if (this.hass.locale.time_zone === TimeZone.server) {
|
||||||
|
const dateRangePicker = this._dateRangePicker;
|
||||||
|
|
||||||
|
const startDate = fromZonedTime(
|
||||||
|
dateRangePicker.start,
|
||||||
|
this.hass.config.time_zone
|
||||||
|
);
|
||||||
|
const endDate = fromZonedTime(
|
||||||
|
dateRangePicker.end,
|
||||||
|
this.hass.config.time_zone
|
||||||
|
);
|
||||||
|
|
||||||
|
dateRangePicker.clickRange([startDate, endDate]);
|
||||||
|
}
|
||||||
|
|
||||||
this._dateRangePicker.clickedApply();
|
this._dateRangePicker.clickedApply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _formatDate(date: Date): string {
|
||||||
|
if (this.hass.locale.time_zone === TimeZone.server) {
|
||||||
|
return toZonedTime(date, this.hass.config.time_zone).toISOString();
|
||||||
|
}
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
private get _dateRangePicker() {
|
private get _dateRangePicker() {
|
||||||
const dateRangePicker = this.shadowRoot!.querySelector(
|
const dateRangePicker = this.shadowRoot!.querySelector(
|
||||||
"date-range-picker"
|
"date-range-picker"
|
||||||
@ -358,6 +384,16 @@ export class HaDateRangePicker extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _handleChange(ev: CustomEvent) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const startDate = ev.detail.startDate;
|
||||||
|
const endDate = ev.detail.endDate;
|
||||||
|
|
||||||
|
fireEvent(this, "value-changed", {
|
||||||
|
value: { startDate, endDate },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
|
|
||||||
ha-icon-button {
|
ha-icon-button {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { css, html, LitElement, svg } from "lit";
|
import { css, html, LitElement, nothing, svg } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { classMap } from "lit/directives/class-map";
|
import { classMap } from "lit/directives/class-map";
|
||||||
import { BRANCH_HEIGHT, SPACING } from "./hat-graph-const";
|
import { BRANCH_HEIGHT, SPACING } from "./hat-graph-const";
|
||||||
@ -41,8 +41,8 @@ export class HatGraphBranch extends LitElement {
|
|||||||
branches.push({
|
branches.push({
|
||||||
x: width / 2 + total_width,
|
x: width / 2 + total_width,
|
||||||
height,
|
height,
|
||||||
start: c.hasAttribute("graphStart"),
|
start: c.hasAttribute("graph-start"),
|
||||||
end: c.hasAttribute("graphEnd"),
|
end: c.hasAttribute("graph-end"),
|
||||||
track: c.hasAttribute("track"),
|
track: c.hasAttribute("track"),
|
||||||
});
|
});
|
||||||
total_width += width;
|
total_width += width;
|
||||||
@ -65,11 +65,8 @@ export class HatGraphBranch extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<slot name="head"></slot>
|
<slot name="head"></slot>
|
||||||
${!this.start
|
${!this.start
|
||||||
? svg`
|
? html`
|
||||||
<svg
|
<svg id="top" width=${this._totalWidth}>
|
||||||
id="top"
|
|
||||||
width="${this._totalWidth}"
|
|
||||||
>
|
|
||||||
${this._branches.map((branch) =>
|
${this._branches.map((branch) =>
|
||||||
branch.start
|
branch.start
|
||||||
? ""
|
? ""
|
||||||
@ -86,7 +83,7 @@ export class HatGraphBranch extends LitElement {
|
|||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
`
|
`
|
||||||
: ""}
|
: nothing}
|
||||||
<div id="branches">
|
<div id="branches">
|
||||||
<svg id="lines" width=${this._totalWidth} height=${this._maxHeight}>
|
<svg id="lines" width=${this._totalWidth} height=${this._maxHeight}>
|
||||||
${this._branches.map((branch) => {
|
${this._branches.map((branch) => {
|
||||||
@ -107,11 +104,8 @@ export class HatGraphBranch extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
${!this.short
|
${!this.short
|
||||||
? svg`
|
? html`
|
||||||
<svg
|
<svg id="bottom" width=${this._totalWidth}>
|
||||||
id="bottom"
|
|
||||||
width="${this._totalWidth}"
|
|
||||||
>
|
|
||||||
${this._branches.map((branch) => {
|
${this._branches.map((branch) => {
|
||||||
if (branch.end) return "";
|
if (branch.end) return "";
|
||||||
return svg`
|
return svg`
|
||||||
@ -128,7 +122,7 @@ export class HatGraphBranch extends LitElement {
|
|||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
`
|
`
|
||||||
: ""}
|
: nothing}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
|
import { memoize } from "@fullcalendar/core/internal";
|
||||||
import { setHours, setMinutes } from "date-fns";
|
import { setHours, setMinutes } from "date-fns";
|
||||||
import type { HassConfig } from "home-assistant-js-websocket";
|
import type { HassConfig } from "home-assistant-js-websocket";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
|
import checkValidDate from "../common/datetime/check_valid_date";
|
||||||
import {
|
import {
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
formatDateTimeNumeric,
|
formatDateTimeNumeric,
|
||||||
@ -11,7 +13,6 @@ import type { HomeAssistant } from "../types";
|
|||||||
import { fileDownload } from "../util/file_download";
|
import { fileDownload } from "../util/file_download";
|
||||||
import { domainToName } from "./integration";
|
import { domainToName } from "./integration";
|
||||||
import type { FrontendLocaleData } from "./translation";
|
import type { FrontendLocaleData } from "./translation";
|
||||||
import checkValidDate from "../common/datetime/check_valid_date";
|
|
||||||
|
|
||||||
export const enum BackupScheduleRecurrence {
|
export const enum BackupScheduleRecurrence {
|
||||||
NEVER = "never",
|
NEVER = "never",
|
||||||
@ -104,6 +105,9 @@ export interface BackupContent {
|
|||||||
name: string;
|
name: string;
|
||||||
agents: Record<string, BackupContentAgent>;
|
agents: Record<string, BackupContentAgent>;
|
||||||
failed_agent_ids?: string[];
|
failed_agent_ids?: string[];
|
||||||
|
extra_metadata?: {
|
||||||
|
"supervisor.addon_update"?: string;
|
||||||
|
};
|
||||||
with_automatic_settings: boolean;
|
with_automatic_settings: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,6 +323,29 @@ export const computeBackupAgentName = (
|
|||||||
export const computeBackupSize = (backup: BackupContent) =>
|
export const computeBackupSize = (backup: BackupContent) =>
|
||||||
Math.max(...Object.values(backup.agents).map((agent) => agent.size));
|
Math.max(...Object.values(backup.agents).map((agent) => agent.size));
|
||||||
|
|
||||||
|
export type BackupType = "automatic" | "manual" | "addon_update";
|
||||||
|
|
||||||
|
const BACKUP_TYPE_ORDER: BackupType[] = ["automatic", "manual", "addon_update"];
|
||||||
|
|
||||||
|
export const getBackupTypes = memoize((isHassio: boolean) =>
|
||||||
|
isHassio
|
||||||
|
? BACKUP_TYPE_ORDER
|
||||||
|
: BACKUP_TYPE_ORDER.filter((type) => type !== "addon_update")
|
||||||
|
);
|
||||||
|
|
||||||
|
export const computeBackupType = (
|
||||||
|
backup: BackupContent,
|
||||||
|
isHassio: boolean
|
||||||
|
): BackupType => {
|
||||||
|
if (backup.with_automatic_settings) {
|
||||||
|
return "automatic";
|
||||||
|
}
|
||||||
|
if (isHassio && backup.extra_metadata?.["supervisor.addon_update"] != null) {
|
||||||
|
return "addon_update";
|
||||||
|
}
|
||||||
|
return "manual";
|
||||||
|
};
|
||||||
|
|
||||||
export const compareAgents = (a: string, b: string) => {
|
export const compareAgents = (a: string, b: string) => {
|
||||||
const isLocalA = isLocalAgent(a);
|
const isLocalA = isLocalAgent(a);
|
||||||
const isLocalB = isLocalAgent(b);
|
const isLocalB = isLocalAgent(b);
|
||||||
|
@ -181,3 +181,6 @@ export const updateCloudGoogleEntityConfig = (
|
|||||||
|
|
||||||
export const cloudSyncGoogleAssistant = (hass: HomeAssistant) =>
|
export const cloudSyncGoogleAssistant = (hass: HomeAssistant) =>
|
||||||
hass.callApi("POST", "cloud/google_actions/sync");
|
hass.callApi("POST", "cloud/google_actions/sync");
|
||||||
|
|
||||||
|
export const fetchSupportPackage = (hass: HomeAssistant) =>
|
||||||
|
hass.callApi<string>("GET", "cloud/support_package");
|
||||||
|
@ -65,7 +65,8 @@ class StepFlowCreateEntry extends LitElement {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
devices.length !== 1 ||
|
devices.length !== 1 ||
|
||||||
devices[0].primary_config_entry !== this.step.result?.entry_id
|
devices[0].primary_config_entry !== this.step.result?.entry_id ||
|
||||||
|
this.step.result.domain === "voip"
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -448,6 +448,10 @@ class MoreInfoUpdate extends LitElement {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin-bottom: -16px;
|
margin-bottom: -16px;
|
||||||
margin-top: -4px;
|
margin-top: -4px;
|
||||||
|
--md-sys-color-surface: var(
|
||||||
|
--ha-dialog-surface-background,
|
||||||
|
var(--mdc-theme-surface, #fff)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ha-md-list-item {
|
ha-md-list-item {
|
||||||
|
@ -47,6 +47,8 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
|||||||
|
|
||||||
@state() private _assistConfiguration?: AssistSatelliteConfiguration;
|
@state() private _assistConfiguration?: AssistSatelliteConfiguration;
|
||||||
|
|
||||||
|
@state() private _error?: string;
|
||||||
|
|
||||||
private _previousSteps: STEP[] = [];
|
private _previousSteps: STEP[] = [];
|
||||||
|
|
||||||
private _nextStep?: STEP;
|
private _nextStep?: STEP;
|
||||||
@ -165,79 +167,86 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
|||||||
"update"
|
"update"
|
||||||
)}
|
)}
|
||||||
></ha-voice-assistant-setup-step-update>`
|
></ha-voice-assistant-setup-step-update>`
|
||||||
: assistEntityState?.state === UNAVAILABLE
|
: this._error
|
||||||
? this.hass.localize(
|
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||||
"ui.panel.config.voice_assistants.satellite_wizard.not_available"
|
: assistEntityState?.state === UNAVAILABLE
|
||||||
)
|
? html`<ha-alert alert-type="error"
|
||||||
: this._step === STEP.CHECK
|
>${this.hass.localize(
|
||||||
? html`<ha-voice-assistant-setup-step-check
|
"ui.panel.config.voice_assistants.satellite_wizard.not_available"
|
||||||
.hass=${this.hass}
|
)}</ha-alert
|
||||||
.assistEntityId=${assistSatelliteEntityId}
|
>`
|
||||||
></ha-voice-assistant-setup-step-check>`
|
: this._step === STEP.CHECK
|
||||||
: this._step === STEP.WAKEWORD
|
? html`<ha-voice-assistant-setup-step-check
|
||||||
? html`<ha-voice-assistant-setup-step-wake-word
|
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.assistConfiguration=${this._assistConfiguration}
|
|
||||||
.assistEntityId=${assistSatelliteEntityId}
|
.assistEntityId=${assistSatelliteEntityId}
|
||||||
.deviceEntities=${this._deviceEntities(
|
></ha-voice-assistant-setup-step-check>`
|
||||||
this._params.deviceId,
|
: this._step === STEP.WAKEWORD
|
||||||
this.hass.entities
|
? html`<ha-voice-assistant-setup-step-wake-word
|
||||||
)}
|
.hass=${this.hass}
|
||||||
></ha-voice-assistant-setup-step-wake-word>`
|
.assistConfiguration=${this._assistConfiguration}
|
||||||
: this._step === STEP.CHANGE_WAKEWORD
|
.assistEntityId=${assistSatelliteEntityId}
|
||||||
? html`
|
.deviceEntities=${this._deviceEntities(
|
||||||
<ha-voice-assistant-setup-step-change-wake-word
|
this._params.deviceId,
|
||||||
.hass=${this.hass}
|
this.hass.entities
|
||||||
.assistConfiguration=${this._assistConfiguration}
|
)}
|
||||||
.assistEntityId=${assistSatelliteEntityId}
|
></ha-voice-assistant-setup-step-wake-word>`
|
||||||
></ha-voice-assistant-setup-step-change-wake-word>
|
: this._step === STEP.CHANGE_WAKEWORD
|
||||||
`
|
|
||||||
: this._step === STEP.AREA
|
|
||||||
? html`
|
? html`
|
||||||
<ha-voice-assistant-setup-step-area
|
<ha-voice-assistant-setup-step-change-wake-word
|
||||||
.hass=${this.hass}
|
|
||||||
.deviceId=${this._params.deviceId}
|
|
||||||
></ha-voice-assistant-setup-step-area>
|
|
||||||
`
|
|
||||||
: this._step === STEP.PIPELINE
|
|
||||||
? html`<ha-voice-assistant-setup-step-pipeline
|
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.assistConfiguration=${this._assistConfiguration}
|
.assistConfiguration=${this._assistConfiguration}
|
||||||
.assistEntityId=${assistSatelliteEntityId}
|
.assistEntityId=${assistSatelliteEntityId}
|
||||||
></ha-voice-assistant-setup-step-pipeline>`
|
></ha-voice-assistant-setup-step-change-wake-word>
|
||||||
: this._step === STEP.CLOUD
|
`
|
||||||
? html`<ha-voice-assistant-setup-step-cloud
|
: this._step === STEP.AREA
|
||||||
|
? html`
|
||||||
|
<ha-voice-assistant-setup-step-area
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
></ha-voice-assistant-setup-step-cloud>`
|
.deviceId=${this._params.deviceId}
|
||||||
: this._step === STEP.LOCAL
|
></ha-voice-assistant-setup-step-area>
|
||||||
? html`<ha-voice-assistant-setup-step-local
|
`
|
||||||
|
: this._step === STEP.PIPELINE
|
||||||
|
? html`<ha-voice-assistant-setup-step-pipeline
|
||||||
|
.hass=${this.hass}
|
||||||
|
.assistConfiguration=${this._assistConfiguration}
|
||||||
|
.assistEntityId=${assistSatelliteEntityId}
|
||||||
|
></ha-voice-assistant-setup-step-pipeline>`
|
||||||
|
: this._step === STEP.CLOUD
|
||||||
|
? html`<ha-voice-assistant-setup-step-cloud
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.assistConfiguration=${this
|
></ha-voice-assistant-setup-step-cloud>`
|
||||||
._assistConfiguration}
|
: this._step === STEP.LOCAL
|
||||||
></ha-voice-assistant-setup-step-local>`
|
? html`<ha-voice-assistant-setup-step-local
|
||||||
: this._step === STEP.SUCCESS
|
|
||||||
? html`<ha-voice-assistant-setup-step-success
|
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.assistConfiguration=${this
|
.assistConfiguration=${this
|
||||||
._assistConfiguration}
|
._assistConfiguration}
|
||||||
.assistEntityId=${assistSatelliteEntityId}
|
></ha-voice-assistant-setup-step-local>`
|
||||||
></ha-voice-assistant-setup-step-success>`
|
: this._step === STEP.SUCCESS
|
||||||
: nothing}
|
? html`<ha-voice-assistant-setup-step-success
|
||||||
|
.hass=${this.hass}
|
||||||
|
.assistConfiguration=${this
|
||||||
|
._assistConfiguration}
|
||||||
|
.assistEntityId=${assistSatelliteEntityId}
|
||||||
|
></ha-voice-assistant-setup-step-success>`
|
||||||
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
</ha-dialog>
|
</ha-dialog>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _fetchAssistConfiguration() {
|
private async _fetchAssistConfiguration() {
|
||||||
this._assistConfiguration = await fetchAssistSatelliteConfiguration(
|
try {
|
||||||
this.hass,
|
this._assistConfiguration = await fetchAssistSatelliteConfiguration(
|
||||||
this._findDomainEntityId(
|
this.hass,
|
||||||
this._params!.deviceId,
|
this._findDomainEntityId(
|
||||||
this.hass.entities,
|
this._params!.deviceId,
|
||||||
"assist_satellite"
|
this.hass.entities,
|
||||||
)!
|
"assist_satellite"
|
||||||
);
|
)!
|
||||||
return this._assistConfiguration;
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
this._error = err.message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _goToPreviousStep() {
|
private _goToPreviousStep() {
|
||||||
@ -293,6 +302,10 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
|||||||
.skip-btn {
|
.skip-btn {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
ha-alert {
|
||||||
|
margin: 24px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -85,7 +85,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
|
|||||||
<div class="rows">
|
<div class="rows">
|
||||||
${this.assistConfiguration &&
|
${this.assistConfiguration &&
|
||||||
this.assistConfiguration.available_wake_words.length > 1
|
this.assistConfiguration.available_wake_words.length > 1
|
||||||
? html` <div class="row">
|
? html`<div class="row">
|
||||||
<ha-select
|
<ha-select
|
||||||
.label=${"Wake word"}
|
.label=${"Wake word"}
|
||||||
@closed=${stopPropagation}
|
@closed=${stopPropagation}
|
||||||
|
@ -44,6 +44,15 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
|
|||||||
protected override willUpdate(changedProperties: PropertyValues) {
|
protected override willUpdate(changedProperties: PropertyValues) {
|
||||||
super.willUpdate(changedProperties);
|
super.willUpdate(changedProperties);
|
||||||
|
|
||||||
|
if (changedProperties.has("assistConfiguration")) {
|
||||||
|
if (
|
||||||
|
this.assistConfiguration &&
|
||||||
|
!this.assistConfiguration.available_wake_words.length
|
||||||
|
) {
|
||||||
|
this._nextStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (changedProperties.has("assistEntityId")) {
|
if (changedProperties.has("assistEntityId")) {
|
||||||
this._detected = false;
|
this._detected = false;
|
||||||
this._muteSwitchEntity = this.deviceEntities?.find(
|
this._muteSwitchEntity = this.deviceEntities?.find(
|
||||||
@ -135,13 +144,16 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
|
|||||||
>`
|
>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
<div class="footer centered">
|
${this.assistConfiguration &&
|
||||||
<ha-button @click=${this._changeWakeWord}
|
this.assistConfiguration.available_wake_words.length > 1
|
||||||
>${this.hass.localize(
|
? html`<div class="footer centered">
|
||||||
"ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word"
|
<ha-button @click=${this._changeWakeWord}
|
||||||
)}</ha-button
|
>${this.hass.localize(
|
||||||
>
|
"ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word"
|
||||||
</div>`;
|
)}</ha-button
|
||||||
|
>
|
||||||
|
</div>`
|
||||||
|
: nothing}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _listenWakeWord() {
|
private async _listenWakeWord() {
|
||||||
|
@ -106,6 +106,7 @@ export class HaConfigApplicationCredentials extends LitElement {
|
|||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
title: "",
|
title: "",
|
||||||
|
label: localize("ui.panel.config.generic.headers.actions"),
|
||||||
type: "overflow-menu",
|
type: "overflow-menu",
|
||||||
showNarrow: true,
|
showNarrow: true,
|
||||||
hideable: false,
|
hideable: false,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { mdiCog, mdiHarddisk, mdiNas } from "@mdi/js";
|
import { mdiCog, mdiDelete, mdiHarddisk, mdiNas } from "@mdi/js";
|
||||||
import { css, html, LitElement, nothing } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
@ -41,13 +41,6 @@ class HaBackupConfigAgents extends LitElement {
|
|||||||
|
|
||||||
@state() private value?: string[];
|
@state() private value?: string[];
|
||||||
|
|
||||||
private _availableAgents = memoizeOne(
|
|
||||||
(agents: BackupAgent[], cloudStatus: CloudStatus) =>
|
|
||||||
agents.filter(
|
|
||||||
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
private get _value() {
|
private get _value() {
|
||||||
return this.value ?? DEFAULT_AGENTS;
|
return this.value ?? DEFAULT_AGENTS;
|
||||||
}
|
}
|
||||||
@ -86,19 +79,84 @@ class HaBackupConfigAgents extends LitElement {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render() {
|
private _availableAgents = memoizeOne(
|
||||||
const agents = this._availableAgents(this.agents, this.cloudStatus);
|
(agents: BackupAgent[], cloudStatus: CloudStatus) =>
|
||||||
|
agents.filter(
|
||||||
|
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
private _unavailableAgents = memoizeOne(
|
||||||
|
(
|
||||||
|
agents: BackupAgent[],
|
||||||
|
cloudStatus: CloudStatus,
|
||||||
|
selectedAgentIds: string[]
|
||||||
|
) => {
|
||||||
|
const availableAgentIds = this._availableAgents(agents, cloudStatus).map(
|
||||||
|
(agent) => agent.agent_id
|
||||||
|
);
|
||||||
|
|
||||||
|
return selectedAgentIds
|
||||||
|
.filter((agent) => !availableAgentIds.includes(agent))
|
||||||
|
.map<BackupAgent>((id) => ({
|
||||||
|
agent_id: id,
|
||||||
|
name: id.split(".")[1] || id, // Use the id as name as it is not available in the list
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
private _renderAgentIcon(agentId: string) {
|
||||||
|
if (isLocalAgent(agentId)) {
|
||||||
|
return html`
|
||||||
|
<ha-svg-icon .path=${mdiHarddisk} slot="start"></ha-svg-icon>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNetworkMountAgent(agentId)) {
|
||||||
|
return html`<ha-svg-icon .path=${mdiNas} slot="start"></ha-svg-icon>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = computeDomain(agentId);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
${agents.length > 0
|
<img
|
||||||
|
.src=${brandsUrl({
|
||||||
|
domain,
|
||||||
|
type: "icon",
|
||||||
|
useFallback: true,
|
||||||
|
darkOptimized: this.hass.themes?.darkMode,
|
||||||
|
})}
|
||||||
|
crossorigin="anonymous"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
alt=""
|
||||||
|
slot="start"
|
||||||
|
/>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
const availableAgents = this._availableAgents(
|
||||||
|
this.agents,
|
||||||
|
this.cloudStatus
|
||||||
|
);
|
||||||
|
const unavailableAgents = this._unavailableAgents(
|
||||||
|
this.agents,
|
||||||
|
this.cloudStatus,
|
||||||
|
this._value
|
||||||
|
);
|
||||||
|
|
||||||
|
const allAgents = [...availableAgents, ...unavailableAgents];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${allAgents.length > 0
|
||||||
? html`
|
? html`
|
||||||
<ha-md-list>
|
<ha-md-list>
|
||||||
${agents.map((agent) => {
|
${availableAgents.map((agent) => {
|
||||||
const agentId = agent.agent_id;
|
const agentId = agent.agent_id;
|
||||||
const domain = computeDomain(agentId);
|
|
||||||
const name = computeBackupAgentName(
|
const name = computeBackupAgentName(
|
||||||
this.hass.localize,
|
this.hass.localize,
|
||||||
agentId,
|
agentId,
|
||||||
this.agents
|
allAgents
|
||||||
);
|
);
|
||||||
const description = this._description(agentId);
|
const description = this._description(agentId);
|
||||||
const noCloudSubscription =
|
const noCloudSubscription =
|
||||||
@ -108,32 +166,7 @@ class HaBackupConfigAgents extends LitElement {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-md-list-item>
|
<ha-md-list-item>
|
||||||
${isLocalAgent(agentId)
|
${this._renderAgentIcon(agentId)}
|
||||||
? html`
|
|
||||||
<ha-svg-icon .path=${mdiHarddisk} slot="start">
|
|
||||||
</ha-svg-icon>
|
|
||||||
`
|
|
||||||
: isNetworkMountAgent(agentId)
|
|
||||||
? html`
|
|
||||||
<ha-svg-icon
|
|
||||||
.path=${mdiNas}
|
|
||||||
slot="start"
|
|
||||||
></ha-svg-icon>
|
|
||||||
`
|
|
||||||
: html`
|
|
||||||
<img
|
|
||||||
.src=${brandsUrl({
|
|
||||||
domain,
|
|
||||||
type: "icon",
|
|
||||||
useFallback: true,
|
|
||||||
darkOptimized: this.hass.themes?.darkMode,
|
|
||||||
})}
|
|
||||||
crossorigin="anonymous"
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
alt=""
|
|
||||||
slot="start"
|
|
||||||
/>
|
|
||||||
`}
|
|
||||||
<div slot="headline" class="name">${name}</div>
|
<div slot="headline" class="name">${name}</div>
|
||||||
${description
|
${description
|
||||||
? html`<div slot="supporting-text">${description}</div>`
|
? html`<div slot="supporting-text">${description}</div>`
|
||||||
@ -151,14 +184,44 @@ class HaBackupConfigAgents extends LitElement {
|
|||||||
<ha-switch
|
<ha-switch
|
||||||
slot="end"
|
slot="end"
|
||||||
id=${agentId}
|
id=${agentId}
|
||||||
.checked=${!noCloudSubscription &&
|
.checked=${this._value.includes(agentId)}
|
||||||
this._value.includes(agentId)}
|
.disabled=${noCloudSubscription &&
|
||||||
.disabled=${noCloudSubscription}
|
!this._value.includes(agentId)}
|
||||||
@change=${this._agentToggled}
|
@change=${this._agentToggled}
|
||||||
></ha-switch>
|
></ha-switch>
|
||||||
</ha-md-list-item>
|
</ha-md-list-item>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
|
${unavailableAgents.length > 0 && this.showSettings
|
||||||
|
? html`
|
||||||
|
<p class="heading">
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.backup.agents.unavailable_agents"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
${unavailableAgents.map((agent) => {
|
||||||
|
const agentId = agent.agent_id;
|
||||||
|
const name = computeBackupAgentName(
|
||||||
|
this.hass.localize,
|
||||||
|
agentId,
|
||||||
|
allAgents
|
||||||
|
);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ha-md-list-item>
|
||||||
|
${this._renderAgentIcon(agentId)}
|
||||||
|
<div slot="headline" class="name">${name}</div>
|
||||||
|
<ha-icon-button
|
||||||
|
id=${agentId}
|
||||||
|
slot="end"
|
||||||
|
path=${mdiDelete}
|
||||||
|
@click=${this._deleteAgent}
|
||||||
|
></ha-icon-button>
|
||||||
|
</ha-md-list-item>
|
||||||
|
`;
|
||||||
|
})}
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
</ha-md-list>
|
</ha-md-list>
|
||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
@ -174,6 +237,13 @@ class HaBackupConfigAgents extends LitElement {
|
|||||||
navigate(`/config/backup/location/${agentId}`);
|
navigate(`/config/backup/location/${agentId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _deleteAgent(ev): void {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const agentId = ev.currentTarget.id;
|
||||||
|
this.value = this._value.filter((agent) => agent !== agentId);
|
||||||
|
fireEvent(this, "value-changed", { value: this.value });
|
||||||
|
}
|
||||||
|
|
||||||
private _agentToggled(ev) {
|
private _agentToggled(ev) {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const value = ev.currentTarget.checked;
|
const value = ev.currentTarget.checked;
|
||||||
@ -185,19 +255,8 @@ class HaBackupConfigAgents extends LitElement {
|
|||||||
this.value = this._value.filter((agent) => agent !== agentId);
|
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
|
// Ensure we don't have duplicates, agents exist in the list and cloud is logged in
|
||||||
this.value = [...new Set(this.value)]
|
this.value = [...new Set(this.value)];
|
||||||
.filter((id) => availableAgents.some((agent) => agent.agent_id === id))
|
|
||||||
.filter(
|
|
||||||
(id) =>
|
|
||||||
id !== CLOUD_AGENT ||
|
|
||||||
(this.cloudStatus.logged_in && this.cloudStatus.active_subscription)
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent(this, "value-changed", { value: this.value });
|
fireEvent(this, "value-changed", { value: this.value });
|
||||||
}
|
}
|
||||||
|
@ -378,8 +378,9 @@ class HaBackupConfigData extends LitElement {
|
|||||||
}
|
}
|
||||||
@media all and (max-width: 450px) {
|
@media all and (max-width: 450px) {
|
||||||
ha-md-select {
|
ha-md-select {
|
||||||
min-width: 160px;
|
min-width: 140px;
|
||||||
width: 160px;
|
width: 140px;
|
||||||
|
--md-filled-field-content-space: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
@ -403,11 +403,11 @@ class HaBackupConfigSchedule extends LitElement {
|
|||||||
backup_create: html`<a
|
backup_create: html`<a
|
||||||
href=${documentationUrl(
|
href=${documentationUrl(
|
||||||
this.hass,
|
this.hass,
|
||||||
"/integrations/backup#example-backing-up-every-night-at-300-am"
|
"/integrations/backup/#action-backupcreate_automatic"
|
||||||
)}
|
)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>backup.create</a
|
>backup.create_automatic</a
|
||||||
>`,
|
>`,
|
||||||
})}</ha-tip
|
})}</ha-tip
|
||||||
>
|
>
|
||||||
@ -537,14 +537,22 @@ class HaBackupConfigSchedule extends LitElement {
|
|||||||
ha-md-list-item {
|
ha-md-list-item {
|
||||||
--md-item-overflow: visible;
|
--md-item-overflow: visible;
|
||||||
}
|
}
|
||||||
ha-md-select,
|
ha-md-select {
|
||||||
ha-time-input {
|
|
||||||
min-width: 210px;
|
min-width: 210px;
|
||||||
}
|
}
|
||||||
|
ha-time-input {
|
||||||
|
min-width: 194px;
|
||||||
|
--time-input-flex: 1;
|
||||||
|
}
|
||||||
@media all and (max-width: 450px) {
|
@media all and (max-width: 450px) {
|
||||||
ha-md-select,
|
ha-md-select {
|
||||||
ha-time-input {
|
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
|
width: 160px;
|
||||||
|
--md-filled-field-content-space: 0;
|
||||||
|
}
|
||||||
|
ha-time-input {
|
||||||
|
min-width: 145px;
|
||||||
|
width: 145px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ha-md-textfield#value {
|
ha-md-textfield#value {
|
||||||
@ -553,6 +561,16 @@ class HaBackupConfigSchedule extends LitElement {
|
|||||||
ha-md-select#type {
|
ha-md-select#type {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
@media all and (max-width: 450px) {
|
||||||
|
ha-md-textfield#value {
|
||||||
|
min-width: 60px;
|
||||||
|
margin: 0 -8px;
|
||||||
|
}
|
||||||
|
ha-md-select#type {
|
||||||
|
min-width: 120px;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
ha-expansion-panel {
|
ha-expansion-panel {
|
||||||
--expansion-panel-summary-padding: 0 16px;
|
--expansion-panel-summary-padding: 0 16px;
|
||||||
--expansion-panel-content-padding: 0 16px;
|
--expansion-panel-content-padding: 0 16px;
|
||||||
|
@ -1,16 +1,19 @@
|
|||||||
import { mdiCalendarSync, mdiGestureTap } from "@mdi/js";
|
import { mdiCalendarSync, mdiGestureTap, mdiPuzzle } from "@mdi/js";
|
||||||
import type { CSSResultGroup } from "lit";
|
import type { CSSResultGroup } from "lit";
|
||||||
import { css, html, LitElement } from "lit";
|
import { css, html, LitElement } from "lit";
|
||||||
import { customElement, property } from "lit/decorators";
|
import { customElement, property } from "lit/decorators";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
|
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
|
||||||
import "../../../../../components/ha-button";
|
import "../../../../../components/ha-button";
|
||||||
import "../../../../../components/ha-card";
|
import "../../../../../components/ha-card";
|
||||||
import "../../../../../components/ha-icon-next";
|
import "../../../../../components/ha-icon-next";
|
||||||
import "../../../../../components/ha-md-list";
|
import "../../../../../components/ha-md-list";
|
||||||
import "../../../../../components/ha-md-list-item";
|
import "../../../../../components/ha-md-list-item";
|
||||||
|
import type { BackupContent, BackupType } from "../../../../../data/backup";
|
||||||
import {
|
import {
|
||||||
computeBackupSize,
|
computeBackupSize,
|
||||||
type BackupContent,
|
computeBackupType,
|
||||||
|
getBackupTypes,
|
||||||
} from "../../../../../data/backup";
|
} from "../../../../../data/backup";
|
||||||
import { haStyle } from "../../../../../resources/styles";
|
import { haStyle } from "../../../../../resources/styles";
|
||||||
import type { HomeAssistant } from "../../../../../types";
|
import type { HomeAssistant } from "../../../../../types";
|
||||||
@ -21,6 +24,12 @@ interface BackupStats {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<BackupType, string> = {
|
||||||
|
automatic: mdiCalendarSync,
|
||||||
|
manual: mdiGestureTap,
|
||||||
|
addon_update: mdiPuzzle,
|
||||||
|
};
|
||||||
|
|
||||||
const computeBackupStats = (backups: BackupContent[]): BackupStats =>
|
const computeBackupStats = (backups: BackupContent[]): BackupStats =>
|
||||||
backups.reduce(
|
backups.reduce(
|
||||||
(stats, backup) => {
|
(stats, backup) => {
|
||||||
@ -37,23 +46,22 @@ class HaBackupOverviewBackups extends LitElement {
|
|||||||
|
|
||||||
@property({ attribute: false }) public backups: BackupContent[] = [];
|
@property({ attribute: false }) public backups: BackupContent[] = [];
|
||||||
|
|
||||||
private _automaticStats = memoizeOne((backups: BackupContent[]) => {
|
private _stats = memoizeOne(
|
||||||
const automaticBackups = backups.filter(
|
(
|
||||||
(backup) => backup.with_automatic_settings
|
backups: BackupContent[],
|
||||||
);
|
isHassio: boolean
|
||||||
return computeBackupStats(automaticBackups);
|
): [BackupType, BackupStats][] =>
|
||||||
});
|
getBackupTypes(isHassio).map((type) => {
|
||||||
|
const backupsOfType = backups.filter(
|
||||||
private _manualStats = memoizeOne((backups: BackupContent[]) => {
|
(backup) => computeBackupType(backup, isHassio) === type
|
||||||
const manualBackups = backups.filter(
|
);
|
||||||
(backup) => !backup.with_automatic_settings
|
return [type, computeBackupStats(backupsOfType)] as const;
|
||||||
);
|
})
|
||||||
return computeBackupStats(manualBackups);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const automaticStats = this._automaticStats(this.backups);
|
const isHassio = isComponentLoaded(this.hass, "hassio");
|
||||||
const manualStats = this._manualStats(this.backups);
|
const stats = this._stats(this.backups, isHassio);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-card class="my-backups">
|
<ha-card class="my-backups">
|
||||||
@ -62,44 +70,32 @@ class HaBackupOverviewBackups extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<ha-md-list>
|
<ha-md-list>
|
||||||
<ha-md-list-item
|
${stats.map(
|
||||||
type="link"
|
([type, { count, size }]) => html`
|
||||||
href="/config/backup/backups?type=automatic"
|
<ha-md-list-item
|
||||||
>
|
type="link"
|
||||||
<ha-svg-icon slot="start" .path=${mdiCalendarSync}></ha-svg-icon>
|
href="/config/backup/backups?type=${type}"
|
||||||
<div slot="headline">
|
>
|
||||||
${this.hass.localize(
|
<ha-svg-icon
|
||||||
"ui.panel.config.backup.overview.backups.automatic",
|
slot="start"
|
||||||
{ count: automaticStats.count }
|
.path=${TYPE_ICONS[type]}
|
||||||
)}
|
></ha-svg-icon>
|
||||||
</div>
|
<div slot="headline">
|
||||||
<div slot="supporting-text">
|
${this.hass.localize(
|
||||||
${this.hass.localize(
|
`ui.panel.config.backup.overview.backups.${type}`,
|
||||||
"ui.panel.config.backup.overview.backups.total_size",
|
{ count }
|
||||||
{ size: bytesToString(automaticStats.size, 1) }
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div slot="supporting-text">
|
||||||
<ha-icon-next slot="end"></ha-icon-next>
|
${this.hass.localize(
|
||||||
</ha-md-list-item>
|
"ui.panel.config.backup.overview.backups.total_size",
|
||||||
<ha-md-list-item
|
{ size: bytesToString(size) }
|
||||||
type="link"
|
)}
|
||||||
href="/config/backup/backups?type=manual"
|
</div>
|
||||||
>
|
<ha-icon-next slot="end"></ha-icon-next>
|
||||||
<ha-svg-icon slot="start" .path=${mdiGestureTap}></ha-svg-icon>
|
</ha-md-list-item>
|
||||||
<div slot="headline">
|
`
|
||||||
${this.hass.localize(
|
)}
|
||||||
"ui.panel.config.backup.overview.backups.manual",
|
|
||||||
{ count: manualStats.count }
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div slot="supporting-text">
|
|
||||||
${this.hass.localize(
|
|
||||||
"ui.panel.config.backup.overview.backups.total_size",
|
|
||||||
{ size: bytesToString(manualStats.size, 1) }
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ha-icon-next slot="end"></ha-icon-next>
|
|
||||||
</ha-md-list-item>
|
|
||||||
</ha-md-list>
|
</ha-md-list>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
|
@ -0,0 +1,225 @@
|
|||||||
|
import { mdiClose } from "@mdi/js";
|
||||||
|
import type { CSSResultGroup } from "lit";
|
||||||
|
import { LitElement, css, html, nothing } from "lit";
|
||||||
|
import { customElement, property, query, state } from "lit/decorators";
|
||||||
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||||
|
import "../../../../components/ha-dialog-header";
|
||||||
|
import "../../../../components/ha-icon-button";
|
||||||
|
import "../../../../components/ha-icon-next";
|
||||||
|
import "../../../../components/ha-md-dialog";
|
||||||
|
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
|
||||||
|
import "../../../../components/ha-md-list";
|
||||||
|
import "../../../../components/ha-md-list-item";
|
||||||
|
import "../../../../components/ha-svg-icon";
|
||||||
|
import "../../../../components/ha-password-field";
|
||||||
|
import "../../../../components/ha-alert";
|
||||||
|
import {
|
||||||
|
canDecryptBackupOnDownload,
|
||||||
|
getPreferredAgentForDownload,
|
||||||
|
} from "../../../../data/backup";
|
||||||
|
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||||
|
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||||
|
import type { HomeAssistant } from "../../../../types";
|
||||||
|
import { downloadBackupFile } from "../helper/download_backup";
|
||||||
|
import type { DownloadDecryptedBackupDialogParams } from "./show-dialog-download-decrypted-backup";
|
||||||
|
|
||||||
|
@customElement("ha-dialog-download-decrypted-backup")
|
||||||
|
class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@state() private _opened = false;
|
||||||
|
|
||||||
|
@state() private _params?: DownloadDecryptedBackupDialogParams;
|
||||||
|
|
||||||
|
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||||
|
|
||||||
|
@state() private _encryptionKey = "";
|
||||||
|
|
||||||
|
@state() private _error = "";
|
||||||
|
|
||||||
|
public showDialog(params: DownloadDecryptedBackupDialogParams): void {
|
||||||
|
this._opened = true;
|
||||||
|
this._params = params;
|
||||||
|
}
|
||||||
|
|
||||||
|
public closeDialog() {
|
||||||
|
this._dialog?.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _dialogClosed() {
|
||||||
|
if (this._opened) {
|
||||||
|
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||||
|
}
|
||||||
|
this._opened = false;
|
||||||
|
this._params = undefined;
|
||||||
|
this._encryptionKey = "";
|
||||||
|
this._error = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
if (!this._opened || !this._params) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ha-md-dialog open @closed=${this._dialogClosed} disable-cancel-action>
|
||||||
|
<ha-dialog-header slot="headline">
|
||||||
|
<ha-icon-button
|
||||||
|
slot="navigationIcon"
|
||||||
|
@click=${this.closeDialog}
|
||||||
|
.label=${this.hass.localize("ui.common.close")}
|
||||||
|
.path=${mdiClose}
|
||||||
|
></ha-icon-button>
|
||||||
|
<span slot="title">
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.backup.dialogs.download.title"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</ha-dialog-header>
|
||||||
|
|
||||||
|
<div slot="content">
|
||||||
|
<p>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.backup.dialogs.download.description"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.backup.dialogs.download.download_backup_encrypted",
|
||||||
|
{
|
||||||
|
download_it_encrypted: html`<button
|
||||||
|
class="link"
|
||||||
|
@click=${this._downloadEncrypted}
|
||||||
|
>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.backup.dialogs.download.download_it_encrypted"
|
||||||
|
)}
|
||||||
|
</button>`,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ha-password-field
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
"ui.panel.config.backup.dialogs.download.encryption_key"
|
||||||
|
)}
|
||||||
|
@input=${this._keyChanged}
|
||||||
|
></ha-password-field>
|
||||||
|
|
||||||
|
${this._error
|
||||||
|
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
<div slot="actions">
|
||||||
|
<ha-button @click=${this._cancel}>
|
||||||
|
${this.hass.localize("ui.dialogs.generic.cancel")}
|
||||||
|
</ha-button>
|
||||||
|
|
||||||
|
<ha-button @click=${this._submit}>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.backup.dialogs.download.download"
|
||||||
|
)}
|
||||||
|
</ha-button>
|
||||||
|
</div>
|
||||||
|
</ha-md-dialog>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cancel() {
|
||||||
|
this.closeDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _submit() {
|
||||||
|
if (this._encryptionKey === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await canDecryptBackupOnDownload(
|
||||||
|
this.hass,
|
||||||
|
this._params!.backup.backup_id,
|
||||||
|
this._agentId,
|
||||||
|
this._encryptionKey
|
||||||
|
);
|
||||||
|
downloadBackupFile(
|
||||||
|
this.hass,
|
||||||
|
this._params!.backup.backup_id,
|
||||||
|
this._agentId,
|
||||||
|
this._encryptionKey
|
||||||
|
);
|
||||||
|
this.closeDialog();
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === "password_incorrect") {
|
||||||
|
this._error = this.hass.localize(
|
||||||
|
"ui.panel.config.backup.dialogs.download.incorrect_encryption_key"
|
||||||
|
);
|
||||||
|
} else if (err?.code === "decrypt_not_supported") {
|
||||||
|
this._error = this.hass.localize(
|
||||||
|
"ui.panel.config.backup.dialogs.download.decryption_not_supported"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _keyChanged(ev) {
|
||||||
|
this._encryptionKey = ev.currentTarget.value;
|
||||||
|
this._error = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private get _agentId() {
|
||||||
|
if (this._params?.agentId) {
|
||||||
|
return this._params.agentId;
|
||||||
|
}
|
||||||
|
return getPreferredAgentForDownload(
|
||||||
|
Object.keys(this._params!.backup.agents)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _downloadEncrypted() {
|
||||||
|
downloadBackupFile(
|
||||||
|
this.hass,
|
||||||
|
this._params!.backup.backup_id,
|
||||||
|
this._agentId
|
||||||
|
);
|
||||||
|
this.closeDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return [
|
||||||
|
haStyle,
|
||||||
|
haStyleDialog,
|
||||||
|
css`
|
||||||
|
ha-md-dialog {
|
||||||
|
--dialog-content-padding: 8px 24px;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||||
|
ha-md-dialog {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
div[slot="content"] {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button.link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ha-dialog-download-decrypted-backup": DialogDownloadDecryptedBackup;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||||
|
import type { BackupContent } from "../../../../data/backup";
|
||||||
|
|
||||||
|
export interface DownloadDecryptedBackupDialogParams {
|
||||||
|
backup: BackupContent;
|
||||||
|
agentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadDownloadDecryptedBackupDialog = () =>
|
||||||
|
import("./dialog-download-decrypted-backup");
|
||||||
|
|
||||||
|
export const showDownloadDecryptedBackupDialog = (
|
||||||
|
element: HTMLElement,
|
||||||
|
params: DownloadDecryptedBackupDialogParams
|
||||||
|
) => {
|
||||||
|
fireEvent(element, "show-dialog", {
|
||||||
|
dialogTag: "ha-dialog-download-decrypted-backup",
|
||||||
|
dialogImport: loadDownloadDecryptedBackupDialog,
|
||||||
|
dialogParams: params,
|
||||||
|
});
|
||||||
|
};
|
@ -11,6 +11,7 @@ import type { CSSResultGroup, TemplateResult } from "lit";
|
|||||||
import { html, LitElement, nothing } from "lit";
|
import { html, LitElement, nothing } from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators";
|
import { customElement, property, query, state } from "lit/decorators";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
|
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||||
import { relativeTime } from "../../../common/datetime/relative_time";
|
import { relativeTime } from "../../../common/datetime/relative_time";
|
||||||
import { storage } from "../../../common/decorators/storage";
|
import { storage } from "../../../common/decorators/storage";
|
||||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||||
@ -42,9 +43,11 @@ import {
|
|||||||
compareAgents,
|
compareAgents,
|
||||||
computeBackupAgentName,
|
computeBackupAgentName,
|
||||||
computeBackupSize,
|
computeBackupSize,
|
||||||
|
computeBackupType,
|
||||||
deleteBackup,
|
deleteBackup,
|
||||||
generateBackup,
|
generateBackup,
|
||||||
generateBackupWithAutomaticSettings,
|
generateBackupWithAutomaticSettings,
|
||||||
|
getBackupTypes,
|
||||||
isLocalAgent,
|
isLocalAgent,
|
||||||
isNetworkMountAgent,
|
isNetworkMountAgent,
|
||||||
} from "../../../data/backup";
|
} from "../../../data/backup";
|
||||||
@ -74,10 +77,6 @@ interface BackupRow extends DataTableRowData, BackupContent {
|
|||||||
agent_ids: string[];
|
agent_ids: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackupType = "automatic" | "manual";
|
|
||||||
|
|
||||||
const TYPE_ORDER: BackupType[] = ["automatic", "manual"];
|
|
||||||
|
|
||||||
@customElement("ha-config-backup-backups")
|
@customElement("ha-config-backup-backups")
|
||||||
class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
@ -141,7 +140,10 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private _columns = memoizeOne(
|
private _columns = memoizeOne(
|
||||||
(localize: LocalizeFunc): DataTableColumnContainer<BackupRow> => ({
|
(
|
||||||
|
localize: LocalizeFunc,
|
||||||
|
maxDisplayedAgents: number
|
||||||
|
): DataTableColumnContainer<BackupRow> => ({
|
||||||
name: {
|
name: {
|
||||||
title: localize("ui.panel.config.backup.name"),
|
title: localize("ui.panel.config.backup.name"),
|
||||||
main: true,
|
main: true,
|
||||||
@ -172,54 +174,75 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
|||||||
locations: {
|
locations: {
|
||||||
title: localize("ui.panel.config.backup.locations"),
|
title: localize("ui.panel.config.backup.locations"),
|
||||||
showNarrow: true,
|
showNarrow: true,
|
||||||
minWidth: "60px",
|
// 24 icon size, 4 gap, 16 left and right padding
|
||||||
template: (backup) => html`
|
minWidth: `${maxDisplayedAgents * 24 + (maxDisplayedAgents - 1) * 4 + 32}px`,
|
||||||
<div style="display: flex; gap: 4px;">
|
template: (backup) => {
|
||||||
${(backup.agent_ids || []).map((agentId) => {
|
const agentIds = backup.agent_ids;
|
||||||
const name = computeBackupAgentName(
|
const displayedAgentIds =
|
||||||
this.hass.localize,
|
agentIds.length > maxDisplayedAgents
|
||||||
agentId,
|
? [...agentIds].splice(0, maxDisplayedAgents - 1)
|
||||||
this.agents
|
: agentIds;
|
||||||
);
|
const agentsMore = Math.max(
|
||||||
if (isLocalAgent(agentId)) {
|
agentIds.length - displayedAgentIds.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return html`
|
||||||
|
<div style="display: flex; gap: 4px;">
|
||||||
|
${displayedAgentIds.map((agentId) => {
|
||||||
|
const name = computeBackupAgentName(
|
||||||
|
this.hass.localize,
|
||||||
|
agentId,
|
||||||
|
this.agents
|
||||||
|
);
|
||||||
|
if (isLocalAgent(agentId)) {
|
||||||
|
return html`
|
||||||
|
<ha-svg-icon
|
||||||
|
.path=${mdiHarddisk}
|
||||||
|
title=${name}
|
||||||
|
style="flex-shrink: 0;"
|
||||||
|
></ha-svg-icon>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (isNetworkMountAgent(agentId)) {
|
||||||
|
return html`
|
||||||
|
<ha-svg-icon
|
||||||
|
.path=${mdiNas}
|
||||||
|
title=${name}
|
||||||
|
style="flex-shrink: 0;"
|
||||||
|
></ha-svg-icon>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
const domain = computeDomain(agentId);
|
||||||
return html`
|
return html`
|
||||||
<ha-svg-icon
|
<img
|
||||||
.path=${mdiHarddisk}
|
|
||||||
title=${name}
|
title=${name}
|
||||||
|
.src=${brandsUrl({
|
||||||
|
domain,
|
||||||
|
type: "icon",
|
||||||
|
useFallback: true,
|
||||||
|
darkOptimized: this.hass.themes?.darkMode,
|
||||||
|
})}
|
||||||
|
height="24"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
alt=${name}
|
||||||
|
slot="graphic"
|
||||||
style="flex-shrink: 0;"
|
style="flex-shrink: 0;"
|
||||||
></ha-svg-icon>
|
/>
|
||||||
`;
|
`;
|
||||||
}
|
})}
|
||||||
if (isNetworkMountAgent(agentId)) {
|
${agentsMore
|
||||||
return html`
|
? html`
|
||||||
<ha-svg-icon
|
<span
|
||||||
.path=${mdiNas}
|
style="display: flex; align-items: center; font-size: 14px;"
|
||||||
title=${name}
|
>
|
||||||
style="flex-shrink: 0;"
|
+${agentsMore}
|
||||||
></ha-svg-icon>
|
</span>
|
||||||
`;
|
`
|
||||||
}
|
: nothing}
|
||||||
const domain = computeDomain(agentId);
|
</div>
|
||||||
return html`
|
`;
|
||||||
<img
|
},
|
||||||
title=${name}
|
|
||||||
.src=${brandsUrl({
|
|
||||||
domain,
|
|
||||||
type: "icon",
|
|
||||||
useFallback: true,
|
|
||||||
darkOptimized: this.hass.themes?.darkMode,
|
|
||||||
})}
|
|
||||||
height="24"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
alt=${name}
|
|
||||||
slot="graphic"
|
|
||||||
style="flex-shrink: 0;"
|
|
||||||
/>
|
|
||||||
`;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
title: "",
|
title: "",
|
||||||
@ -253,9 +276,13 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
private _groupOrder = memoizeOne(
|
private _groupOrder = memoizeOne(
|
||||||
(activeGrouping: string | undefined, localize: LocalizeFunc) =>
|
(
|
||||||
|
activeGrouping: string | undefined,
|
||||||
|
localize: LocalizeFunc,
|
||||||
|
isHassio: boolean
|
||||||
|
) =>
|
||||||
activeGrouping === "formatted_type"
|
activeGrouping === "formatted_type"
|
||||||
? TYPE_ORDER.map((type) =>
|
? getBackupTypes(isHassio).map((type) =>
|
||||||
localize(`ui.panel.config.backup.type.${type}`)
|
localize(`ui.panel.config.backup.type.${type}`)
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
@ -279,33 +306,48 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
|||||||
(
|
(
|
||||||
backups: BackupContent[],
|
backups: BackupContent[],
|
||||||
filters: DataTableFiltersValues,
|
filters: DataTableFiltersValues,
|
||||||
localize: LocalizeFunc
|
localize: LocalizeFunc,
|
||||||
|
isHassio: boolean
|
||||||
): BackupRow[] => {
|
): BackupRow[] => {
|
||||||
const typeFilter = filters["ha-filter-states"] as string[] | undefined;
|
const typeFilter = filters["ha-filter-states"] as string[] | undefined;
|
||||||
let filteredBackups = backups;
|
let filteredBackups = backups;
|
||||||
if (typeFilter?.length) {
|
if (typeFilter?.length) {
|
||||||
filteredBackups = filteredBackups.filter(
|
filteredBackups = filteredBackups.filter((backup) => {
|
||||||
(backup) =>
|
const type = computeBackupType(backup, isHassio);
|
||||||
(backup.with_automatic_settings &&
|
return typeFilter.includes(type);
|
||||||
typeFilter.includes("automatic")) ||
|
});
|
||||||
(!backup.with_automatic_settings && typeFilter.includes("manual"))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return filteredBackups.map((backup) => {
|
return filteredBackups.map((backup) => {
|
||||||
const type = backup.with_automatic_settings ? "automatic" : "manual";
|
const type = computeBackupType(backup, isHassio);
|
||||||
|
const agentIds = Object.keys(backup.agents);
|
||||||
return {
|
return {
|
||||||
...backup,
|
...backup,
|
||||||
size: computeBackupSize(backup),
|
size: computeBackupSize(backup),
|
||||||
agent_ids: Object.keys(backup.agents).sort(compareAgents),
|
agent_ids: agentIds.sort(compareAgents),
|
||||||
formatted_type: localize(`ui.panel.config.backup.type.${type}`),
|
formatted_type: localize(`ui.panel.config.backup.type.${type}`),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _maxAgents = memoizeOne((data: BackupRow[]): number =>
|
||||||
|
Math.max(...data.map((row) => row.agent_ids.length))
|
||||||
|
);
|
||||||
|
|
||||||
protected render(): TemplateResult {
|
protected render(): TemplateResult {
|
||||||
const backupInProgress =
|
const backupInProgress =
|
||||||
"state" in this.manager && this.manager.state === "in_progress";
|
"state" in this.manager && this.manager.state === "in_progress";
|
||||||
|
const isHassio = isComponentLoaded(this.hass, "hassio");
|
||||||
|
const data = this._data(
|
||||||
|
this.backups,
|
||||||
|
this._filters,
|
||||||
|
this.hass.localize,
|
||||||
|
isHassio
|
||||||
|
);
|
||||||
|
const maxDisplayedAgents = Math.min(
|
||||||
|
this._maxAgents(data),
|
||||||
|
this.narrow ? 3 : 5
|
||||||
|
);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<hass-tabs-subpage-data-table
|
<hass-tabs-subpage-data-table
|
||||||
@ -336,15 +378,16 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
|||||||
.initialCollapsedGroups=${this._activeCollapsed}
|
.initialCollapsedGroups=${this._activeCollapsed}
|
||||||
.groupOrder=${this._groupOrder(
|
.groupOrder=${this._groupOrder(
|
||||||
this._activeGrouping,
|
this._activeGrouping,
|
||||||
this.hass.localize
|
this.hass.localize,
|
||||||
|
isHassio
|
||||||
)}
|
)}
|
||||||
@grouping-changed=${this._handleGroupingChanged}
|
@grouping-changed=${this._handleGroupingChanged}
|
||||||
@collapsed-changed=${this._handleCollapseChanged}
|
@collapsed-changed=${this._handleCollapseChanged}
|
||||||
@selection-changed=${this._handleSelectionChanged}
|
@selection-changed=${this._handleSelectionChanged}
|
||||||
.route=${this.route}
|
.route=${this.route}
|
||||||
@row-click=${this._showBackupDetails}
|
@row-click=${this._showBackupDetails}
|
||||||
.columns=${this._columns(this.hass.localize)}
|
.columns=${this._columns(this.hass.localize, maxDisplayedAgents)}
|
||||||
.data=${this._data(this.backups, this._filters, this.hass.localize)}
|
.data=${data}
|
||||||
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
|
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
|
||||||
.searchLabel=${this.hass.localize(
|
.searchLabel=${this.hass.localize(
|
||||||
"ui.panel.config.backup.picker.search"
|
"ui.panel.config.backup.picker.search"
|
||||||
@ -400,7 +443,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.label=${this.hass.localize("ui.panel.config.backup.backup_type")}
|
.label=${this.hass.localize("ui.panel.config.backup.backup_type")}
|
||||||
.value=${this._filters["ha-filter-states"]}
|
.value=${this._filters["ha-filter-states"]}
|
||||||
.states=${this._states(this.hass.localize)}
|
.states=${this._states(this.hass.localize, isHassio)}
|
||||||
@data-table-filter-changed=${this._filterChanged}
|
@data-table-filter-changed=${this._filterChanged}
|
||||||
slot="filter-pane"
|
slot="filter-pane"
|
||||||
expanded
|
expanded
|
||||||
@ -425,8 +468,8 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _states = memoizeOne((localize: LocalizeFunc) =>
|
private _states = memoizeOne((localize: LocalizeFunc, isHassio: boolean) =>
|
||||||
TYPE_ORDER.map((type) => ({
|
getBackupTypes(isHassio).map((type) => ({
|
||||||
value: type,
|
value: type,
|
||||||
label: localize(`ui.panel.config.backup.type.${type}`),
|
label: localize(`ui.panel.config.backup.type.${type}`),
|
||||||
}))
|
}))
|
||||||
@ -496,12 +539,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _downloadBackup(backup: BackupContent): Promise<void> {
|
private async _downloadBackup(backup: BackupContent): Promise<void> {
|
||||||
downloadBackup(
|
downloadBackup(this.hass, this, backup, this.config);
|
||||||
this.hass,
|
|
||||||
this,
|
|
||||||
backup,
|
|
||||||
this.config?.create_backup.password
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _deleteBackup(backup: BackupContent): Promise<void> {
|
private async _deleteBackup(backup: BackupContent): Promise<void> {
|
||||||
|
@ -31,6 +31,7 @@ import {
|
|||||||
compareAgents,
|
compareAgents,
|
||||||
computeBackupAgentName,
|
computeBackupAgentName,
|
||||||
computeBackupSize,
|
computeBackupSize,
|
||||||
|
computeBackupType,
|
||||||
deleteBackup,
|
deleteBackup,
|
||||||
fetchBackupDetails,
|
fetchBackupDetails,
|
||||||
isLocalAgent,
|
isLocalAgent,
|
||||||
@ -46,6 +47,7 @@ import { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup";
|
|||||||
import { fireEvent } from "../../../common/dom/fire_event";
|
import { fireEvent } from "../../../common/dom/fire_event";
|
||||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||||
import { downloadBackup } from "./helper/download_backup";
|
import { downloadBackup } from "./helper/download_backup";
|
||||||
|
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||||
|
|
||||||
interface Agent extends BackupContentAgent {
|
interface Agent extends BackupContentAgent {
|
||||||
id: string;
|
id: string;
|
||||||
@ -110,6 +112,8 @@ class HaConfigBackupDetails extends LitElement {
|
|||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isHassio = isComponentLoaded(this.hass, "hassio");
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<hass-subpage
|
<hass-subpage
|
||||||
back-path="/config/backup/backups"
|
back-path="/config/backup/backups"
|
||||||
@ -161,6 +165,18 @@ class HaConfigBackupDetails extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<ha-md-list class="summary">
|
<ha-md-list class="summary">
|
||||||
|
<ha-md-list-item>
|
||||||
|
<span slot="headline">
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.backup.backup_type"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span slot="supporting-text">
|
||||||
|
${this.hass.localize(
|
||||||
|
`ui.panel.config.backup.type.${computeBackupType(this._backup, isHassio)}`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</ha-md-list-item>
|
||||||
<ha-md-list-item>
|
<ha-md-list-item>
|
||||||
<span slot="headline">
|
<span slot="headline">
|
||||||
${this.hass.localize(
|
${this.hass.localize(
|
||||||
@ -401,13 +417,7 @@ class HaConfigBackupDetails extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _downloadBackup(agentId?: string): Promise<void> {
|
private async _downloadBackup(agentId?: string): Promise<void> {
|
||||||
await downloadBackup(
|
await downloadBackup(this.hass, this, this._backup!, this.config, agentId);
|
||||||
this.hass,
|
|
||||||
this,
|
|
||||||
this._backup!,
|
|
||||||
this.config?.create_backup.password,
|
|
||||||
agentId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _deleteBackup(): Promise<void> {
|
private async _deleteBackup(): Promise<void> {
|
||||||
|
@ -221,8 +221,7 @@ class HaConfigBackupOverview extends LitElement {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-bottom: 24px;
|
margin-bottom: calc(env(safe-area-inset-bottom) + 72px);
|
||||||
margin-bottom: 72px;
|
|
||||||
}
|
}
|
||||||
.card-actions {
|
.card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -50,9 +50,11 @@ class HaConfigBackupSettings extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected firstUpdated(_changedProperties: PropertyValues): void {
|
public connectedCallback(): void {
|
||||||
super.firstUpdated(_changedProperties);
|
super.connectedCallback();
|
||||||
this._scrollToSection();
|
this._scrollToSection();
|
||||||
|
// Update config the page is displayed (e.g. when coming back from a location detail page)
|
||||||
|
this._config = this.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _scrollToSection() {
|
private async _scrollToSection() {
|
||||||
|
@ -119,6 +119,7 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
|
|||||||
settings: {
|
settings: {
|
||||||
tag: "ha-config-backup-settings",
|
tag: "ha-config-backup-settings",
|
||||||
load: () => import("./ha-config-backup-settings"),
|
load: () => import("./ha-config-backup-settings"),
|
||||||
|
cache: true,
|
||||||
},
|
},
|
||||||
location: {
|
location: {
|
||||||
tag: "ha-config-backup-location",
|
tag: "ha-config-backup-location",
|
||||||
|
@ -1,20 +1,17 @@
|
|||||||
import type { LitElement } from "lit";
|
import type { LitElement } from "lit";
|
||||||
|
import { getSignedPath } from "../../../../data/auth";
|
||||||
|
import type { BackupConfig, BackupContent } from "../../../../data/backup";
|
||||||
import {
|
import {
|
||||||
canDecryptBackupOnDownload,
|
canDecryptBackupOnDownload,
|
||||||
getBackupDownloadUrl,
|
getBackupDownloadUrl,
|
||||||
getPreferredAgentForDownload,
|
getPreferredAgentForDownload,
|
||||||
type BackupContent,
|
|
||||||
} from "../../../../data/backup";
|
} from "../../../../data/backup";
|
||||||
import type { HomeAssistant } from "../../../../types";
|
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";
|
import { fileDownload } from "../../../../util/file_download";
|
||||||
|
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
|
||||||
|
import { showDownloadDecryptedBackupDialog } from "../dialogs/show-dialog-download-decrypted-backup";
|
||||||
|
|
||||||
const triggerDownload = async (
|
export const downloadBackupFile = async (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
backupId: string,
|
backupId: string,
|
||||||
preferedAgent: string,
|
preferedAgent: string,
|
||||||
@ -27,120 +24,80 @@ const triggerDownload = async (
|
|||||||
fileDownload(signedUrl.path);
|
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 (
|
export const downloadBackup = async (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
element: LitElement,
|
element: LitElement,
|
||||||
backup: BackupContent,
|
backup: BackupContent,
|
||||||
encryptionKey?: string | null,
|
backupConfig?: BackupConfig,
|
||||||
agentId?: string,
|
agentId?: string
|
||||||
userProvided = false
|
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const agentIds = Object.keys(backup.agents);
|
const agentIds = Object.keys(backup.agents);
|
||||||
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
|
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
|
||||||
const isProtected = backup.agents[preferedAgent]?.protected;
|
const isProtected = backup.agents[preferedAgent]?.protected;
|
||||||
|
|
||||||
if (isProtected) {
|
if (!isProtected) {
|
||||||
if (encryptionKey) {
|
downloadBackupFile(hass, backup.backup_id, preferedAgent);
|
||||||
try {
|
return;
|
||||||
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);
|
const encryptionKey = backupConfig?.create_backup?.password;
|
||||||
|
|
||||||
|
if (!encryptionKey) {
|
||||||
|
showDownloadDecryptedBackupDialog(element, {
|
||||||
|
backup,
|
||||||
|
agentId: preferedAgent,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if we can decrypt it
|
||||||
|
await canDecryptBackupOnDownload(
|
||||||
|
hass,
|
||||||
|
backup.backup_id,
|
||||||
|
preferedAgent,
|
||||||
|
encryptionKey
|
||||||
|
);
|
||||||
|
downloadBackupFile(hass, backup.backup_id, preferedAgent, encryptionKey);
|
||||||
|
} catch (err: any) {
|
||||||
|
// If encryption key is incorrect, ask for encryption key
|
||||||
|
if (err?.code === "password_incorrect") {
|
||||||
|
showDownloadDecryptedBackupDialog(element, {
|
||||||
|
backup,
|
||||||
|
agentId: preferedAgent,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If decryption is not supported, ask for confirmation and download it encrypted
|
||||||
|
if (err?.code === "decrypt_not_supported") {
|
||||||
|
showAlertDialog(element, {
|
||||||
|
title: hass.localize(
|
||||||
|
"ui.panel.config.backup.dialogs.download.decryption_unsupported_title"
|
||||||
|
),
|
||||||
|
text: hass.localize(
|
||||||
|
"ui.panel.config.backup.dialogs.download.decryption_unsupported"
|
||||||
|
),
|
||||||
|
confirm() {
|
||||||
|
downloadBackupFile(hass, backup.backup_id, preferedAgent);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else, show generic error
|
||||||
|
showAlertDialog(element, {
|
||||||
|
title: hass.localize(
|
||||||
|
"ui.panel.config.backup.dialogs.download.error_check_title",
|
||||||
|
{
|
||||||
|
error: err.message,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
text: hass.localize(
|
||||||
|
"ui.panel.config.backup.dialogs.download.error_check_description",
|
||||||
|
{
|
||||||
|
error: err.message,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import "@material/mwc-button";
|
import "@material/mwc-button";
|
||||||
|
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
|
||||||
import { css, html, LitElement } from "lit";
|
import { css, html, LitElement } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js";
|
|
||||||
import { formatDateTime } from "../../../../common/datetime/format_date_time";
|
import { formatDateTime } from "../../../../common/datetime/format_date_time";
|
||||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||||
import { debounce } from "../../../../common/util/debounce";
|
import { debounce } from "../../../../common/util/debounce";
|
||||||
import "../../../../components/ha-alert";
|
import "../../../../components/ha-alert";
|
||||||
import "../../../../components/ha-card";
|
|
||||||
import "../../../../components/ha-tip";
|
|
||||||
import "../../../../components/ha-list-item";
|
|
||||||
import "../../../../components/ha-button-menu";
|
import "../../../../components/ha-button-menu";
|
||||||
|
import "../../../../components/ha-card";
|
||||||
|
import "../../../../components/ha-list-item";
|
||||||
|
import "../../../../components/ha-tip";
|
||||||
import type {
|
import type {
|
||||||
CloudStatusLoggedIn,
|
CloudStatusLoggedIn,
|
||||||
SubscriptionInfo,
|
SubscriptionInfo,
|
||||||
@ -32,6 +32,7 @@ import "./cloud-ice-servers-pref";
|
|||||||
import "./cloud-remote-pref";
|
import "./cloud-remote-pref";
|
||||||
import "./cloud-tts-pref";
|
import "./cloud-tts-pref";
|
||||||
import "./cloud-webhooks";
|
import "./cloud-webhooks";
|
||||||
|
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
|
||||||
|
|
||||||
@customElement("cloud-account")
|
@customElement("cloud-account")
|
||||||
export class CloudAccount extends SubscribeMixin(LitElement) {
|
export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||||
@ -52,7 +53,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
|||||||
.narrow=${this.narrow}
|
.narrow=${this.narrow}
|
||||||
header="Home Assistant Cloud"
|
header="Home Assistant Cloud"
|
||||||
>
|
>
|
||||||
<ha-button-menu slot="toolbar-icon" @action=${this._deleteCloudData}>
|
<ha-button-menu slot="toolbar-icon" @action=${this._handleMenuAction}>
|
||||||
<ha-icon-button
|
<ha-icon-button
|
||||||
slot="trigger"
|
slot="trigger"
|
||||||
.label=${this.hass.localize("ui.common.menu")}
|
.label=${this.hass.localize("ui.common.menu")}
|
||||||
@ -65,6 +66,12 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
|||||||
)}
|
)}
|
||||||
<ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon>
|
<ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon>
|
||||||
</ha-list-item>
|
</ha-list-item>
|
||||||
|
<ha-list-item graphic="icon">
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.cloud.account.download_support_package"
|
||||||
|
)}
|
||||||
|
<ha-svg-icon slot="graphic" .path=${mdiDownload}></ha-svg-icon>
|
||||||
|
</ha-list-item>
|
||||||
</ha-button-menu>
|
</ha-button-menu>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<ha-config-section .isWide=${this.isWide}>
|
<ha-config-section .isWide=${this.isWide}>
|
||||||
@ -286,6 +293,16 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
|||||||
fireEvent(this, "ha-refresh-cloud-status");
|
fireEvent(this, "ha-refresh-cloud-status");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _handleMenuAction(ev) {
|
||||||
|
switch (ev.detail.index) {
|
||||||
|
case 0:
|
||||||
|
this._deleteCloudData();
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
this._downloadSupportPackage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async _deleteCloudData() {
|
private async _deleteCloudData() {
|
||||||
const confirm = await showConfirmationDialog(this, {
|
const confirm = await showConfirmationDialog(this, {
|
||||||
title: this.hass.localize(
|
title: this.hass.localize(
|
||||||
@ -316,6 +333,10 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _downloadSupportPackage() {
|
||||||
|
showSupportPackageDialog(this);
|
||||||
|
}
|
||||||
|
|
||||||
static get styles() {
|
static get styles() {
|
||||||
return [
|
return [
|
||||||
haStyle,
|
haStyle,
|
||||||
|
206
src/panels/config/cloud/account/dialog-cloud-support-package.ts
Normal file
206
src/panels/config/cloud/account/dialog-cloud-support-package.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import "@material/mwc-button";
|
||||||
|
import "@material/mwc-list/mwc-list-item";
|
||||||
|
import { mdiClose } from "@mdi/js";
|
||||||
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
|
import { customElement, property, query, state } from "lit/decorators";
|
||||||
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||||
|
import "../../../../components/ha-alert";
|
||||||
|
import "../../../../components/ha-button";
|
||||||
|
import "../../../../components/ha-circular-progress";
|
||||||
|
import "../../../../components/ha-dialog-header";
|
||||||
|
import "../../../../components/ha-markdown-element";
|
||||||
|
import "../../../../components/ha-md-dialog";
|
||||||
|
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
|
||||||
|
import "../../../../components/ha-select";
|
||||||
|
import "../../../../components/ha-textarea";
|
||||||
|
import { fetchSupportPackage } from "../../../../data/cloud";
|
||||||
|
import type { HomeAssistant } from "../../../../types";
|
||||||
|
import { fileDownload } from "../../../../util/file_download";
|
||||||
|
|
||||||
|
@customElement("dialog-cloud-support-package")
|
||||||
|
export class DialogSupportPackage extends LitElement {
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@state() private _open = false;
|
||||||
|
|
||||||
|
@state() private _supportPackage?: string;
|
||||||
|
|
||||||
|
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||||
|
|
||||||
|
public showDialog() {
|
||||||
|
this._open = true;
|
||||||
|
this._loadSupportPackage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _dialogClosed(): void {
|
||||||
|
this._open = false;
|
||||||
|
this._supportPackage = undefined;
|
||||||
|
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public closeDialog() {
|
||||||
|
this._dialog?.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
if (!this._open) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<ha-md-dialog open @closed=${this._dialogClosed}>
|
||||||
|
<ha-dialog-header slot="headline">
|
||||||
|
<ha-icon-button
|
||||||
|
slot="navigationIcon"
|
||||||
|
.label=${this.hass.localize("ui.common.close")}
|
||||||
|
.path=${mdiClose}
|
||||||
|
@click=${this.closeDialog}
|
||||||
|
></ha-icon-button>
|
||||||
|
<span slot="title">Download support package</span>
|
||||||
|
</ha-dialog-header>
|
||||||
|
|
||||||
|
<div slot="content">
|
||||||
|
${this._supportPackage
|
||||||
|
? html`<ha-markdown-element
|
||||||
|
.content=${this._supportPackage}
|
||||||
|
breaks
|
||||||
|
></ha-markdown-element>`
|
||||||
|
: html`
|
||||||
|
<div class="progress-container">
|
||||||
|
<ha-circular-progress indeterminate></ha-circular-progress>
|
||||||
|
Generating preview...
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
<div class="footer" slot="actions">
|
||||||
|
<ha-alert>
|
||||||
|
This file may contain personal data about your home. Avoid sharing
|
||||||
|
them with unverified or untrusted parties.
|
||||||
|
</ha-alert>
|
||||||
|
<hr />
|
||||||
|
<div class="actions">
|
||||||
|
<ha-button @click=${this.closeDialog}>Close</ha-button>
|
||||||
|
<ha-button @click=${this._download}>Download</ha-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ha-md-dialog>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _loadSupportPackage() {
|
||||||
|
this._supportPackage = await fetchSupportPackage(this.hass);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _download() {
|
||||||
|
fileDownload(
|
||||||
|
"data:text/plain;charset=utf-8," +
|
||||||
|
encodeURIComponent(this._supportPackage || ""),
|
||||||
|
"support-package.md"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
ha-md-dialog {
|
||||||
|
min-width: 90vw;
|
||||||
|
min-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: calc(90vh - 260px);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||||
|
ha-md-dialog {
|
||||||
|
min-width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.progress-container {
|
||||||
|
height: calc(100vh - 260px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--divider-color);
|
||||||
|
width: calc(100% + 48px);
|
||||||
|
margin-right: -24px;
|
||||||
|
margin-left: -24px;
|
||||||
|
}
|
||||||
|
table,
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
display: table;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table tr {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table > tbody > tr:nth-child(odd) {
|
||||||
|
background-color: rgba(var(--rgb-primary-text-color), 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
table > tbody > tr > td {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table > tbody > tr {
|
||||||
|
-webkit-transition: background-color 0.25s ease;
|
||||||
|
transition: background-color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
table > tbody > tr:hover {
|
||||||
|
background-color: rgba(var(--rgb-primary-text-color), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
padding: 15px 5px;
|
||||||
|
display: table-cell;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
details {
|
||||||
|
background-color: var(--secondary-background-color);
|
||||||
|
padding: 16px 24px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"dialog-cloud-support-package": DialogSupportPackage;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||||
|
|
||||||
|
export const loadSupportPackageDialog = () =>
|
||||||
|
import("./dialog-cloud-support-package");
|
||||||
|
|
||||||
|
export const showSupportPackageDialog = (element: HTMLElement): void => {
|
||||||
|
fireEvent(element, "show-dialog", {
|
||||||
|
dialogTag: "dialog-cloud-support-package",
|
||||||
|
dialogImport: loadSupportPackageDialog,
|
||||||
|
dialogParams: {},
|
||||||
|
});
|
||||||
|
};
|
@ -1,6 +1,6 @@
|
|||||||
import "@material/mwc-button";
|
import "@material/mwc-button";
|
||||||
import "@material/mwc-list/mwc-list";
|
import "@material/mwc-list/mwc-list";
|
||||||
import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js";
|
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
|
||||||
import type { TemplateResult } from "lit";
|
import type { TemplateResult } from "lit";
|
||||||
import { css, html, LitElement } from "lit";
|
import { css, html, LitElement } from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators";
|
import { customElement, property, query, state } from "lit/decorators";
|
||||||
@ -27,6 +27,7 @@ import "../../../../layouts/hass-subpage";
|
|||||||
import { haStyle } from "../../../../resources/styles";
|
import { haStyle } from "../../../../resources/styles";
|
||||||
import type { HomeAssistant } from "../../../../types";
|
import type { HomeAssistant } from "../../../../types";
|
||||||
import "../../ha-config-section";
|
import "../../ha-config-section";
|
||||||
|
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
|
||||||
|
|
||||||
@customElement("cloud-login")
|
@customElement("cloud-login")
|
||||||
export class CloudLogin extends LitElement {
|
export class CloudLogin extends LitElement {
|
||||||
@ -57,7 +58,7 @@ export class CloudLogin extends LitElement {
|
|||||||
.narrow=${this.narrow}
|
.narrow=${this.narrow}
|
||||||
header="Home Assistant Cloud"
|
header="Home Assistant Cloud"
|
||||||
>
|
>
|
||||||
<ha-button-menu slot="toolbar-icon" @action=${this._deleteCloudData}>
|
<ha-button-menu slot="toolbar-icon" @action=${this._handleMenuAction}>
|
||||||
<ha-icon-button
|
<ha-icon-button
|
||||||
slot="trigger"
|
slot="trigger"
|
||||||
.label=${this.hass.localize("ui.common.menu")}
|
.label=${this.hass.localize("ui.common.menu")}
|
||||||
@ -70,6 +71,12 @@ export class CloudLogin extends LitElement {
|
|||||||
)}
|
)}
|
||||||
<ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon>
|
<ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon>
|
||||||
</ha-list-item>
|
</ha-list-item>
|
||||||
|
<ha-list-item graphic="icon">
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.cloud.account.download_support_package"
|
||||||
|
)}
|
||||||
|
<ha-svg-icon slot="graphic" .path=${mdiDownload}></ha-svg-icon>
|
||||||
|
</ha-list-item>
|
||||||
</ha-button-menu>
|
</ha-button-menu>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<ha-config-section .isWide=${this.isWide}>
|
<ha-config-section .isWide=${this.isWide}>
|
||||||
@ -348,6 +355,16 @@ export class CloudLogin extends LitElement {
|
|||||||
fireEvent(this, "flash-message-changed", { value: "" });
|
fireEvent(this, "flash-message-changed", { value: "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _handleMenuAction(ev) {
|
||||||
|
switch (ev.detail.index) {
|
||||||
|
case 0:
|
||||||
|
this._deleteCloudData();
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
this._downloadSupportPackage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async _deleteCloudData() {
|
private async _deleteCloudData() {
|
||||||
const confirm = await showConfirmationDialog(this, {
|
const confirm = await showConfirmationDialog(this, {
|
||||||
title: this.hass.localize(
|
title: this.hass.localize(
|
||||||
@ -377,6 +394,10 @@ export class CloudLogin extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _downloadSupportPackage() {
|
||||||
|
showSupportPackageDialog(this);
|
||||||
|
}
|
||||||
|
|
||||||
static get styles() {
|
static get styles() {
|
||||||
return [
|
return [
|
||||||
haStyle,
|
haStyle,
|
||||||
|
@ -1073,7 +1073,14 @@ export class HaConfigDevicePage extends LitElement {
|
|||||||
(ent) => computeDomain(ent.entity_id) === "assist_satellite"
|
(ent) => computeDomain(ent.entity_id) === "assist_satellite"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const domains = this._integrations(
|
||||||
|
device,
|
||||||
|
this.entries,
|
||||||
|
this.manifests
|
||||||
|
).map((int) => int.domain);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
!domains.includes("voip") &&
|
||||||
assistSatellite &&
|
assistSatellite &&
|
||||||
assistSatelliteSupportsSetupFlow(
|
assistSatelliteSupportsSetupFlow(
|
||||||
this.hass.states[assistSatellite.entity_id]
|
this.hass.states[assistSatellite.entity_id]
|
||||||
@ -1088,12 +1095,6 @@ export class HaConfigDevicePage extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const domains = this._integrations(
|
|
||||||
device,
|
|
||||||
this.entries,
|
|
||||||
this.manifests
|
|
||||||
).map((int) => int.domain);
|
|
||||||
|
|
||||||
if (domains.includes("mqtt")) {
|
if (domains.includes("mqtt")) {
|
||||||
const mqtt = await import(
|
const mqtt = await import(
|
||||||
"./device-detail/integration-elements/mqtt/device-actions"
|
"./device-detail/integration-elements/mqtt/device-actions"
|
||||||
|
@ -39,7 +39,6 @@ import { hardwareBrandsUrl } from "../../../util/brands-url";
|
|||||||
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
|
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
|
||||||
import { extractApiErrorMessage } from "../../../data/hassio/common";
|
import { extractApiErrorMessage } from "../../../data/hassio/common";
|
||||||
import type { ECOption } from "../../../resources/echarts";
|
import type { ECOption } from "../../../resources/echarts";
|
||||||
import { getTimeAxisLabelConfig } from "../../../components/chart/axis-label";
|
|
||||||
|
|
||||||
const DATASAMPLES = 60;
|
const DATASAMPLES = 60;
|
||||||
|
|
||||||
@ -153,13 +152,6 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
|
|||||||
this._chartOptions = {
|
this._chartOptions = {
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: "time",
|
type: "time",
|
||||||
axisLabel: getTimeAxisLabelConfig(this.hass.locale, this.hass.config),
|
|
||||||
splitLine: {
|
|
||||||
show: true,
|
|
||||||
},
|
|
||||||
axisLine: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: "value",
|
type: "value",
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
|
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||||
import { html, LitElement } from "lit";
|
import { html, LitElement } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
import { storage } from "../../../../../common/decorators/storage";
|
||||||
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
|
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
|
||||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||||
import type {
|
import type {
|
||||||
@ -11,9 +12,6 @@ import type {
|
|||||||
} from "../../../../../components/data-table/ha-data-table";
|
} from "../../../../../components/data-table/ha-data-table";
|
||||||
import "../../../../../components/ha-fab";
|
import "../../../../../components/ha-fab";
|
||||||
import "../../../../../components/ha-icon-button";
|
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 {
|
import type {
|
||||||
BluetoothDeviceData,
|
BluetoothDeviceData,
|
||||||
BluetoothScannersDetails,
|
BluetoothScannersDetails,
|
||||||
@ -22,6 +20,10 @@ import {
|
|||||||
subscribeBluetoothAdvertisements,
|
subscribeBluetoothAdvertisements,
|
||||||
subscribeBluetoothScannersDetails,
|
subscribeBluetoothScannersDetails,
|
||||||
} from "../../../../../data/bluetooth";
|
} from "../../../../../data/bluetooth";
|
||||||
|
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
|
||||||
|
import "../../../../../layouts/hass-tabs-subpage-data-table";
|
||||||
|
import { haStyle } from "../../../../../resources/styles";
|
||||||
|
import type { HomeAssistant, Route } from "../../../../../types";
|
||||||
import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info";
|
import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info";
|
||||||
|
|
||||||
@customElement("bluetooth-advertisement-monitor")
|
@customElement("bluetooth-advertisement-monitor")
|
||||||
@ -38,6 +40,22 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
|
|||||||
|
|
||||||
@state() private _scanners: BluetoothScannersDetails = {};
|
@state() private _scanners: BluetoothScannersDetails = {};
|
||||||
|
|
||||||
|
@state() private _sourceDevices: Record<string, DeviceRegistryEntry> = {};
|
||||||
|
|
||||||
|
@storage({
|
||||||
|
key: "bluetooth-advertisement-table-grouping",
|
||||||
|
state: false,
|
||||||
|
subscribe: false,
|
||||||
|
})
|
||||||
|
private _activeGrouping?: string = "source";
|
||||||
|
|
||||||
|
@storage({
|
||||||
|
key: "bluetooth-advertisement-table-collapsed",
|
||||||
|
state: false,
|
||||||
|
subscribe: false,
|
||||||
|
})
|
||||||
|
private _activeCollapsed: string[] = [];
|
||||||
|
|
||||||
private _unsub_advertisements?: UnsubscribeFunc;
|
private _unsub_advertisements?: UnsubscribeFunc;
|
||||||
|
|
||||||
private _unsub_scanners?: UnsubscribeFunc;
|
private _unsub_scanners?: UnsubscribeFunc;
|
||||||
@ -57,6 +75,19 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
|
|||||||
this._scanners = scanners;
|
this._scanners = scanners;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const devices = Object.values(this.hass.devices);
|
||||||
|
const bluetoothDevices = devices.filter((device) =>
|
||||||
|
device.connections.find((connection) => connection[0] === "bluetooth")
|
||||||
|
);
|
||||||
|
this._sourceDevices = Object.fromEntries(
|
||||||
|
bluetoothDevices.map((device) => {
|
||||||
|
const connection = device.connections.find(
|
||||||
|
(c) => c[0] === "bluetooth"
|
||||||
|
)!;
|
||||||
|
return [connection[1], device];
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,21 +115,35 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
|
|||||||
hideable: false,
|
hideable: false,
|
||||||
moveable: false,
|
moveable: false,
|
||||||
direction: "asc",
|
direction: "asc",
|
||||||
flex: 2,
|
flex: 1,
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
title: localize("ui.panel.config.bluetooth.name"),
|
title: localize("ui.panel.config.bluetooth.name"),
|
||||||
filterable: true,
|
filterable: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
},
|
},
|
||||||
|
device: {
|
||||||
|
title: localize("ui.panel.config.bluetooth.device"),
|
||||||
|
filterable: true,
|
||||||
|
sortable: true,
|
||||||
|
template: (data) => html`${data.device || "-"}`,
|
||||||
|
},
|
||||||
source: {
|
source: {
|
||||||
title: localize("ui.panel.config.bluetooth.source"),
|
title: localize("ui.panel.config.bluetooth.source"),
|
||||||
filterable: true,
|
filterable: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
groupable: true,
|
||||||
|
},
|
||||||
|
source_address: {
|
||||||
|
title: localize("ui.panel.config.bluetooth.source_address"),
|
||||||
|
filterable: true,
|
||||||
|
sortable: true,
|
||||||
|
defaultHidden: true,
|
||||||
},
|
},
|
||||||
rssi: {
|
rssi: {
|
||||||
title: localize("ui.panel.config.bluetooth.rssi"),
|
title: localize("ui.panel.config.bluetooth.rssi"),
|
||||||
type: "numeric",
|
type: "numeric",
|
||||||
|
maxWidth: "60px",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -108,11 +153,22 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
|
|||||||
);
|
);
|
||||||
|
|
||||||
private _dataWithNamedSourceAndIds = memoizeOne((data) =>
|
private _dataWithNamedSourceAndIds = memoizeOne((data) =>
|
||||||
data.map((row) => ({
|
data.map((row) => {
|
||||||
...row,
|
const device = this._sourceDevices[row.address];
|
||||||
id: row.address,
|
const scannerDevice = this._sourceDevices[row.source];
|
||||||
source: this._scanners[row.source]?.name || row.source,
|
const scanner = this._scanners[row.source];
|
||||||
}))
|
return {
|
||||||
|
...row,
|
||||||
|
id: row.address,
|
||||||
|
source_address: row.source,
|
||||||
|
source:
|
||||||
|
scannerDevice?.name_by_user ||
|
||||||
|
scannerDevice?.name ||
|
||||||
|
scanner?.name ||
|
||||||
|
row.source,
|
||||||
|
device: device?.name_by_user || device?.name || undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
protected render(): TemplateResult {
|
protected render(): TemplateResult {
|
||||||
@ -124,11 +180,23 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
|
|||||||
.columns=${this._columns(this.hass.localize)}
|
.columns=${this._columns(this.hass.localize)}
|
||||||
.data=${this._dataWithNamedSourceAndIds(this._data)}
|
.data=${this._dataWithNamedSourceAndIds(this._data)}
|
||||||
@row-click=${this._handleRowClicked}
|
@row-click=${this._handleRowClicked}
|
||||||
|
.initialGroupColumn=${this._activeGrouping}
|
||||||
|
.initialCollapsedGroups=${this._activeCollapsed}
|
||||||
|
@grouping-changed=${this._handleGroupingChanged}
|
||||||
|
@collapsed-changed=${this._handleCollapseChanged}
|
||||||
clickable
|
clickable
|
||||||
></hass-tabs-subpage-data-table>
|
></hass-tabs-subpage-data-table>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _handleGroupingChanged(ev: CustomEvent) {
|
||||||
|
this._activeGrouping = ev.detail.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleCollapseChanged(ev: CustomEvent) {
|
||||||
|
this._activeCollapsed = ev.detail.value;
|
||||||
|
}
|
||||||
|
|
||||||
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
|
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
|
||||||
const entry = this._data.find((ent) => ent.address === ev.detail.id);
|
const entry = this._data.find((ent) => ent.address === ev.detail.id);
|
||||||
showBluetoothDeviceInfoDialog(this, {
|
showBluetoothDeviceInfoDialog(this, {
|
||||||
|
@ -53,8 +53,6 @@ class DialogBluetoothDeviceInfo extends LitElement implements HassDialog {
|
|||||||
return html`
|
return html`
|
||||||
<ha-dialog
|
<ha-dialog
|
||||||
open
|
open
|
||||||
scrimClickAction
|
|
||||||
escapeKeyAction
|
|
||||||
@closed=${this.closeDialog}
|
@closed=${this.closeDialog}
|
||||||
.heading=${createCloseHeading(
|
.heading=${createCloseHeading(
|
||||||
this.hass,
|
this.hass,
|
||||||
|
@ -25,7 +25,7 @@ import { styleMap } from "lit/directives/style-map";
|
|||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
import { computeCssColor } from "../../../common/color/compute-color";
|
import { computeCssColor } from "../../../common/color/compute-color";
|
||||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||||
import { formatShortDateTime } from "../../../common/datetime/format_date_time";
|
import { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time";
|
||||||
import { relativeTime } from "../../../common/datetime/relative_time";
|
import { relativeTime } from "../../../common/datetime/relative_time";
|
||||||
import { storage } from "../../../common/decorators/storage";
|
import { storage } from "../../../common/decorators/storage";
|
||||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||||
@ -304,7 +304,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
|||||||
return html`
|
return html`
|
||||||
${script.last_triggered
|
${script.last_triggered
|
||||||
? dayDifference > 3
|
? dayDifference > 3
|
||||||
? formatShortDateTime(
|
? formatShortDateTimeWithConditionalYear(
|
||||||
date,
|
date,
|
||||||
this.hass.locale,
|
this.hass.locale,
|
||||||
this.hass.config
|
this.hass.config
|
||||||
|
@ -173,7 +173,7 @@ class HaPanelHistory extends LitElement {
|
|||||||
.endDate=${this._endDate}
|
.endDate=${this._endDate}
|
||||||
extended-presets
|
extended-presets
|
||||||
time-picker
|
time-picker
|
||||||
@change=${this._dateRangeChanged}
|
@value-changed=${this._dateRangeChanged}
|
||||||
></ha-date-range-picker>
|
></ha-date-range-picker>
|
||||||
<ha-target-picker
|
<ha-target-picker
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
@ -424,8 +424,8 @@ class HaPanelHistory extends LitElement {
|
|||||||
);
|
);
|
||||||
|
|
||||||
private _dateRangeChanged(ev) {
|
private _dateRangeChanged(ev) {
|
||||||
this._startDate = ev.detail.startDate;
|
this._startDate = ev.detail.value.startDate;
|
||||||
const endDate = ev.detail.endDate;
|
const endDate = ev.detail.value.endDate;
|
||||||
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
|
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
|
||||||
endDate.setDate(endDate.getDate() + 1);
|
endDate.setDate(endDate.getDate() + 1);
|
||||||
endDate.setMilliseconds(endDate.getMilliseconds() - 1);
|
endDate.setMilliseconds(endDate.getMilliseconds() - 1);
|
||||||
|
@ -93,7 +93,7 @@ export class HaPanelLogbook extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.startDate=${this._time.range[0]}
|
.startDate=${this._time.range[0]}
|
||||||
.endDate=${this._time.range[1]}
|
.endDate=${this._time.range[1]}
|
||||||
@change=${this._dateRangeChanged}
|
@value-changed=${this._dateRangeChanged}
|
||||||
time-picker
|
time-picker
|
||||||
></ha-date-range-picker>
|
></ha-date-range-picker>
|
||||||
|
|
||||||
@ -233,8 +233,8 @@ export class HaPanelLogbook extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _dateRangeChanged(ev) {
|
private _dateRangeChanged(ev) {
|
||||||
const startDate = ev.detail.startDate;
|
const startDate = ev.detail.value.startDate;
|
||||||
const endDate = ev.detail.endDate;
|
const endDate = ev.detail.value.endDate;
|
||||||
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
|
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
|
||||||
endDate.setDate(endDate.getDate() + 1);
|
endDate.setDate(endDate.getDate() + 1);
|
||||||
endDate.setMilliseconds(endDate.getMilliseconds() - 1);
|
endDate.setMilliseconds(endDate.getMilliseconds() - 1);
|
||||||
|
@ -1,5 +1,16 @@
|
|||||||
import type { HassConfig } from "home-assistant-js-websocket";
|
import type { HassConfig } from "home-assistant-js-websocket";
|
||||||
import { addHours, subHours, differenceInDays } from "date-fns";
|
import {
|
||||||
|
differenceInMonths,
|
||||||
|
subHours,
|
||||||
|
differenceInDays,
|
||||||
|
differenceInYears,
|
||||||
|
startOfYear,
|
||||||
|
addMilliseconds,
|
||||||
|
startOfMonth,
|
||||||
|
addYears,
|
||||||
|
addMonths,
|
||||||
|
addHours,
|
||||||
|
} from "date-fns";
|
||||||
import type {
|
import type {
|
||||||
BarSeriesOption,
|
BarSeriesOption,
|
||||||
CallbackDataParams,
|
CallbackDataParams,
|
||||||
@ -7,10 +18,12 @@ import type {
|
|||||||
} from "echarts/types/dist/shared";
|
} from "echarts/types/dist/shared";
|
||||||
import type { FrontendLocaleData } from "../../../../../data/translation";
|
import type { FrontendLocaleData } from "../../../../../data/translation";
|
||||||
import { formatNumber } from "../../../../../common/number/format_number";
|
import { formatNumber } from "../../../../../common/number/format_number";
|
||||||
import { formatDateVeryShort } from "../../../../../common/datetime/format_date";
|
import {
|
||||||
|
formatDateMonthYear,
|
||||||
|
formatDateVeryShort,
|
||||||
|
} from "../../../../../common/datetime/format_date";
|
||||||
import { formatTime } from "../../../../../common/datetime/format_time";
|
import { formatTime } from "../../../../../common/datetime/format_time";
|
||||||
import type { ECOption } from "../../../../../resources/echarts";
|
import type { ECOption } from "../../../../../resources/echarts";
|
||||||
import { getTimeAxisLabelConfig } from "../../../../../components/chart/axis-label";
|
|
||||||
|
|
||||||
export function getSuggestedMax(dayDifference: number, end: Date): number {
|
export function getSuggestedMax(dayDifference: number, end: Date): number {
|
||||||
let suggestedMax = new Date(end);
|
let suggestedMax = new Date(end);
|
||||||
@ -52,23 +65,9 @@ export function getCommonOptions(
|
|||||||
|
|
||||||
const options: ECOption = {
|
const options: ECOption = {
|
||||||
xAxis: {
|
xAxis: {
|
||||||
id: "xAxisMain",
|
|
||||||
type: "time",
|
type: "time",
|
||||||
min: start.getTime(),
|
min: start,
|
||||||
max: getSuggestedMax(dayDifference, end),
|
max: getSuggestedMax(dayDifference, end),
|
||||||
axisLabel: getTimeAxisLabelConfig(locale, config, dayDifference),
|
|
||||||
axisLine: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
splitLine: {
|
|
||||||
show: true,
|
|
||||||
},
|
|
||||||
minInterval:
|
|
||||||
dayDifference >= 89 // quarter
|
|
||||||
? 28 * 3600 * 24 * 1000
|
|
||||||
: dayDifference > 2
|
|
||||||
? 3600 * 24 * 1000
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: "value",
|
type: "value",
|
||||||
@ -103,7 +102,6 @@ export function getCommonOptions(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return [mainItems, compareItems]
|
return [mainItems, compareItems]
|
||||||
.filter((items) => items.length > 0)
|
|
||||||
.map((items) =>
|
.map((items) =>
|
||||||
formatTooltip(
|
formatTooltip(
|
||||||
items,
|
items,
|
||||||
@ -115,6 +113,7 @@ export function getCommonOptions(
|
|||||||
formatTotal
|
formatTotal
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.filter(Boolean)
|
||||||
.join("<br><br>");
|
.join("<br><br>");
|
||||||
}
|
}
|
||||||
return formatTooltip(
|
return formatTooltip(
|
||||||
@ -141,14 +140,16 @@ function formatTooltip(
|
|||||||
unit?: string,
|
unit?: string,
|
||||||
formatTotal?: (total: number) => string
|
formatTotal?: (total: number) => string
|
||||||
) {
|
) {
|
||||||
if (!params[0].value) {
|
if (!params[0]?.value) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
// when comparing the first value is offset to match the main period
|
// when comparing the first value is offset to match the main period
|
||||||
// and the real date is in the third value
|
// and the real date is in the third value
|
||||||
const date = new Date(params[0].value?.[2] ?? params[0].value?.[0]);
|
const date = new Date(params[0].value?.[2] ?? params[0].value?.[0]);
|
||||||
let period: string;
|
let period: string;
|
||||||
if (dayDifference > 0) {
|
if (dayDifference > 89) {
|
||||||
|
period = `${formatDateMonthYear(date, locale, config)}`;
|
||||||
|
} else if (dayDifference > 0) {
|
||||||
period = `${formatDateVeryShort(date, locale, config)}`;
|
period = `${formatDateVeryShort(date, locale, config)}`;
|
||||||
} else {
|
} else {
|
||||||
period = `${
|
period = `${
|
||||||
@ -198,7 +199,9 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
|
|||||||
const buckets = Array.from(
|
const buckets = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
datasets
|
datasets
|
||||||
.map((dataset) => dataset.data!.map((datapoint) => datapoint![0]))
|
.map((dataset) =>
|
||||||
|
dataset.data!.map((datapoint) => Number(datapoint![0]))
|
||||||
|
)
|
||||||
.flat()
|
.flat()
|
||||||
)
|
)
|
||||||
).sort((a, b) => a - b);
|
).sort((a, b) => a - b);
|
||||||
@ -257,3 +260,25 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCompareTransform(start: Date, compareStart?: Date) {
|
||||||
|
if (!compareStart) {
|
||||||
|
return (ts: Date) => ts;
|
||||||
|
}
|
||||||
|
const compareYearDiff = differenceInYears(start, compareStart);
|
||||||
|
if (
|
||||||
|
compareYearDiff !== 0 &&
|
||||||
|
start.getTime() === startOfYear(start).getTime()
|
||||||
|
) {
|
||||||
|
return (ts: Date) => addYears(ts, compareYearDiff);
|
||||||
|
}
|
||||||
|
const compareMonthDiff = differenceInMonths(start, compareStart);
|
||||||
|
if (
|
||||||
|
compareMonthDiff !== 0 &&
|
||||||
|
start.getTime() === startOfMonth(start).getTime()
|
||||||
|
) {
|
||||||
|
return (ts: Date) => addMonths(ts, compareMonthDiff);
|
||||||
|
}
|
||||||
|
const compareOffset = start.getTime() - compareStart.getTime();
|
||||||
|
return (ts: Date) => addMilliseconds(ts, compareOffset);
|
||||||
|
}
|
||||||
|
@ -33,6 +33,7 @@ import { hasConfigChanged } from "../../common/has-changed";
|
|||||||
import {
|
import {
|
||||||
fillDataGapsAndRoundCaps,
|
fillDataGapsAndRoundCaps,
|
||||||
getCommonOptions,
|
getCommonOptions,
|
||||||
|
getCompareTransform,
|
||||||
} from "./common/energy-chart-options";
|
} from "./common/energy-chart-options";
|
||||||
import { storage } from "../../../../common/decorators/storage";
|
import { storage } from "../../../../common/decorators/storage";
|
||||||
import type { ECOption } from "../../../../resources/echarts";
|
import type { ECOption } from "../../../../resources/echarts";
|
||||||
@ -314,29 +315,34 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
|
|
||||||
processedData.forEach((device) => {
|
processedData.forEach((device) => {
|
||||||
device.data.forEach((datapoint) => {
|
device.data.forEach((datapoint) => {
|
||||||
totalDeviceConsumption[datapoint[0]] =
|
totalDeviceConsumption[datapoint[compare ? 2 : 0]] =
|
||||||
(totalDeviceConsumption[datapoint[0]] || 0) + datapoint[1];
|
(totalDeviceConsumption[datapoint[compare ? 2 : 0]] || 0) +
|
||||||
|
datapoint[1];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const compareOffset = compare
|
const compareTransform = getCompareTransform(
|
||||||
? this._start.getTime() - this._compareStart!.getTime()
|
this._start,
|
||||||
: 0;
|
this._compareStart!
|
||||||
|
);
|
||||||
|
|
||||||
const untrackedConsumption: BarSeriesOption["data"] = [];
|
const untrackedConsumption: BarSeriesOption["data"] = [];
|
||||||
Object.keys(consumptionData.total).forEach((time) => {
|
Object.keys(consumptionData.total).forEach((time) => {
|
||||||
|
const ts = Number(time);
|
||||||
const value =
|
const value =
|
||||||
consumptionData.total[time] - (totalDeviceConsumption[time] || 0);
|
consumptionData.total[time] - (totalDeviceConsumption[time] || 0);
|
||||||
const dataPoint = [Number(time), value];
|
const dataPoint: number[] = [ts, value];
|
||||||
if (compare) {
|
if (compare) {
|
||||||
dataPoint[2] = dataPoint[0];
|
dataPoint[2] = dataPoint[0];
|
||||||
dataPoint[0] += compareOffset;
|
dataPoint[0] = compareTransform(new Date(ts)).getTime();
|
||||||
}
|
}
|
||||||
untrackedConsumption.push(dataPoint);
|
untrackedConsumption.push(dataPoint);
|
||||||
});
|
});
|
||||||
|
// random id to always add untracked at the end
|
||||||
|
const order = Date.now();
|
||||||
const dataset: BarSeriesOption = {
|
const dataset: BarSeriesOption = {
|
||||||
type: "bar",
|
type: "bar",
|
||||||
cursor: "default",
|
cursor: "default",
|
||||||
id: compare ? "compare-untracked" : "untracked",
|
id: compare ? `compare-untracked-${order}` : `untracked-${order}`,
|
||||||
name: this.hass.localize(
|
name: this.hass.localize(
|
||||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||||
),
|
),
|
||||||
@ -372,9 +378,10 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
compare = false
|
compare = false
|
||||||
) {
|
) {
|
||||||
const data: BarSeriesOption[] = [];
|
const data: BarSeriesOption[] = [];
|
||||||
const compareOffset = compare
|
const compareTransform = getCompareTransform(
|
||||||
? this._start.getTime() - this._compareStart!.getTime()
|
this._start,
|
||||||
: 0;
|
this._compareStart!
|
||||||
|
);
|
||||||
|
|
||||||
devices.forEach((source, idx) => {
|
devices.forEach((source, idx) => {
|
||||||
const order = sorted_devices.indexOf(source.stat_consumption);
|
const order = sorted_devices.indexOf(source.stat_consumption);
|
||||||
@ -409,7 +416,7 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
const dataPoint = [point.start, point.change];
|
const dataPoint = [point.start, point.change];
|
||||||
if (compare) {
|
if (compare) {
|
||||||
dataPoint[2] = dataPoint[0];
|
dataPoint[2] = dataPoint[0];
|
||||||
dataPoint[0] += compareOffset;
|
dataPoint[0] = compareTransform(new Date(point.start)).getTime();
|
||||||
}
|
}
|
||||||
consumptionData.push(dataPoint);
|
consumptionData.push(dataPoint);
|
||||||
prevStart = point.start;
|
prevStart = point.start;
|
||||||
@ -419,9 +426,10 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
data.push({
|
data.push({
|
||||||
type: "bar",
|
type: "bar",
|
||||||
cursor: "default",
|
cursor: "default",
|
||||||
|
// add order to id, otherwise echarts refuses to reorder them
|
||||||
id: compare
|
id: compare
|
||||||
? `compare-${source.stat_consumption}`
|
? `compare-${source.stat_consumption}-${order}`
|
||||||
: source.stat_consumption,
|
: `${source.stat_consumption}-${order}`,
|
||||||
name:
|
name:
|
||||||
source.name ||
|
source.name ||
|
||||||
getStatisticLabel(
|
getStatisticLabel(
|
||||||
@ -438,7 +446,9 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
stack: compare ? "devicesCompare" : "devices",
|
stack: compare ? "devicesCompare" : "devices",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return data;
|
return sorted_devices.map(
|
||||||
|
(device) => data.find((d) => (d.id as string).includes(device))!
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
|
@ -88,7 +88,7 @@ export class HuiEnergyDevicesGraphCard
|
|||||||
<ha-chart-base
|
<ha-chart-base
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.data=${this._chartData}
|
.data=${this._chartData}
|
||||||
.options=${this._createOptions(this.hass.themes?.darkMode)}
|
.options=${this._createOptions(this._chartData)}
|
||||||
.height=${`${(this._chartData[0]?.data?.length || 0) * 28 + 50}px`}
|
.height=${`${(this._chartData[0]?.data?.length || 0) * 28 + 50}px`}
|
||||||
@chart-click=${this._handleChartClick}
|
@chart-click=${this._handleChartClick}
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
@ -110,18 +110,17 @@ export class HuiEnergyDevicesGraphCard
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _createOptions = memoizeOne(
|
private _createOptions = memoizeOne(
|
||||||
(darkMode: boolean): ECOption => ({
|
(data: BarSeriesOption[]): ECOption => ({
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: "value",
|
type: "value",
|
||||||
name: "kWh",
|
name: "kWh",
|
||||||
splitLine: {
|
|
||||||
lineStyle: darkMode ? { opacity: 0.15 } : {},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: "category",
|
type: "category",
|
||||||
inverse: true,
|
inverse: true,
|
||||||
triggerEvent: true,
|
triggerEvent: true,
|
||||||
|
// take order from data
|
||||||
|
data: data[0]?.data?.map((d: any) => d.value[1]),
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
formatter: this._getDeviceName.bind(this),
|
formatter: this._getDeviceName.bind(this),
|
||||||
overflow: "truncate",
|
overflow: "truncate",
|
||||||
|
@ -29,6 +29,7 @@ import { hasConfigChanged } from "../../common/has-changed";
|
|||||||
import {
|
import {
|
||||||
fillDataGapsAndRoundCaps,
|
fillDataGapsAndRoundCaps,
|
||||||
getCommonOptions,
|
getCommonOptions,
|
||||||
|
getCompareTransform,
|
||||||
} from "./common/energy-chart-options";
|
} from "./common/energy-chart-options";
|
||||||
import type { ECOption } from "../../../../resources/echarts";
|
import type { ECOption } from "../../../../resources/echarts";
|
||||||
|
|
||||||
@ -213,9 +214,10 @@ export class HuiEnergyGasGraphCard
|
|||||||
compare = false
|
compare = false
|
||||||
) {
|
) {
|
||||||
const data: BarSeriesOption[] = [];
|
const data: BarSeriesOption[] = [];
|
||||||
const compareOffset = compare
|
const compareTransform = getCompareTransform(
|
||||||
? this._start.getTime() - this._compareStart!.getTime()
|
this._start,
|
||||||
: 0;
|
this._compareStart!
|
||||||
|
);
|
||||||
|
|
||||||
gasSources.forEach((source, idx) => {
|
gasSources.forEach((source, idx) => {
|
||||||
let prevStart: number | null = null;
|
let prevStart: number | null = null;
|
||||||
@ -236,10 +238,13 @@ export class HuiEnergyGasGraphCard
|
|||||||
if (prevStart === point.start) {
|
if (prevStart === point.start) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const dataPoint = [point.start, point.change];
|
const dataPoint: (Date | string | number)[] = [
|
||||||
|
point.start,
|
||||||
|
point.change,
|
||||||
|
];
|
||||||
if (compare) {
|
if (compare) {
|
||||||
dataPoint[2] = dataPoint[0];
|
dataPoint[2] = dataPoint[0];
|
||||||
dataPoint[0] += compareOffset;
|
dataPoint[0] = compareTransform(new Date(point.start));
|
||||||
}
|
}
|
||||||
gasConsumptionData.push(dataPoint);
|
gasConsumptionData.push(dataPoint);
|
||||||
prevStart = point.start;
|
prevStart = point.start;
|
||||||
|
@ -30,6 +30,7 @@ import { hasConfigChanged } from "../../common/has-changed";
|
|||||||
import {
|
import {
|
||||||
fillDataGapsAndRoundCaps,
|
fillDataGapsAndRoundCaps,
|
||||||
getCommonOptions,
|
getCommonOptions,
|
||||||
|
getCompareTransform,
|
||||||
} from "./common/energy-chart-options";
|
} from "./common/energy-chart-options";
|
||||||
import type { ECOption } from "../../../../resources/echarts";
|
import type { ECOption } from "../../../../resources/echarts";
|
||||||
|
|
||||||
@ -231,9 +232,10 @@ export class HuiEnergySolarGraphCard
|
|||||||
compare = false
|
compare = false
|
||||||
) {
|
) {
|
||||||
const data: BarSeriesOption[] = [];
|
const data: BarSeriesOption[] = [];
|
||||||
const compareOffset = compare
|
const compareTransform = getCompareTransform(
|
||||||
? this._start.getTime() - this._compareStart!.getTime()
|
this._start,
|
||||||
: 0;
|
this._compareStart!
|
||||||
|
);
|
||||||
|
|
||||||
solarSources.forEach((source, idx) => {
|
solarSources.forEach((source, idx) => {
|
||||||
let prevStart: number | null = null;
|
let prevStart: number | null = null;
|
||||||
@ -255,10 +257,13 @@ export class HuiEnergySolarGraphCard
|
|||||||
if (prevStart === point.start) {
|
if (prevStart === point.start) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const dataPoint = [point.start, point.change];
|
const dataPoint: (Date | string | number)[] = [
|
||||||
|
point.start,
|
||||||
|
point.change,
|
||||||
|
];
|
||||||
if (compare) {
|
if (compare) {
|
||||||
dataPoint[2] = dataPoint[0];
|
dataPoint[2] = dataPoint[0];
|
||||||
dataPoint[0] += compareOffset;
|
dataPoint[0] = compareTransform(new Date(point.start));
|
||||||
}
|
}
|
||||||
solarProductionData.push(dataPoint);
|
solarProductionData.push(dataPoint);
|
||||||
prevStart = point.start;
|
prevStart = point.start;
|
||||||
|
@ -27,6 +27,7 @@ import { hasConfigChanged } from "../../common/has-changed";
|
|||||||
import {
|
import {
|
||||||
fillDataGapsAndRoundCaps,
|
fillDataGapsAndRoundCaps,
|
||||||
getCommonOptions,
|
getCommonOptions,
|
||||||
|
getCompareTransform,
|
||||||
} from "./common/energy-chart-options";
|
} from "./common/energy-chart-options";
|
||||||
import type { ECOption } from "../../../../resources/echarts";
|
import type { ECOption } from "../../../../resources/echarts";
|
||||||
|
|
||||||
@ -476,9 +477,10 @@ export class HuiEnergyUsageGraphCard
|
|||||||
(a, b) => Number(a) - Number(b)
|
(a, b) => Number(a) - Number(b)
|
||||||
);
|
);
|
||||||
|
|
||||||
const compareOffset = compare
|
const compareTransform = getCompareTransform(
|
||||||
? this._start.getTime() - this._compareStart!.getTime()
|
this._start,
|
||||||
: 0;
|
this._compareStart!
|
||||||
|
);
|
||||||
|
|
||||||
Object.entries(combinedData).forEach(([type, sources]) => {
|
Object.entries(combinedData).forEach(([type, sources]) => {
|
||||||
Object.entries(sources).forEach(([statId, source]) => {
|
Object.entries(sources).forEach(([statId, source]) => {
|
||||||
@ -494,7 +496,7 @@ export class HuiEnergyUsageGraphCard
|
|||||||
];
|
];
|
||||||
if (compare) {
|
if (compare) {
|
||||||
dataPoint[2] = dataPoint[0];
|
dataPoint[2] = dataPoint[0];
|
||||||
dataPoint[0] += compareOffset;
|
dataPoint[0] = compareTransform(dataPoint[0]);
|
||||||
}
|
}
|
||||||
points.push(dataPoint);
|
points.push(dataPoint);
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,7 @@ import { hasConfigChanged } from "../../common/has-changed";
|
|||||||
import {
|
import {
|
||||||
fillDataGapsAndRoundCaps,
|
fillDataGapsAndRoundCaps,
|
||||||
getCommonOptions,
|
getCommonOptions,
|
||||||
|
getCompareTransform,
|
||||||
} from "./common/energy-chart-options";
|
} from "./common/energy-chart-options";
|
||||||
import type { ECOption } from "../../../../resources/echarts";
|
import type { ECOption } from "../../../../resources/echarts";
|
||||||
import { formatNumber } from "../../../../common/number/format_number";
|
import { formatNumber } from "../../../../common/number/format_number";
|
||||||
@ -211,9 +212,10 @@ export class HuiEnergyWaterGraphCard
|
|||||||
compare = false
|
compare = false
|
||||||
) {
|
) {
|
||||||
const data: BarSeriesOption[] = [];
|
const data: BarSeriesOption[] = [];
|
||||||
const compareOffset = compare
|
const compareTransform = getCompareTransform(
|
||||||
? this._start.getTime() - this._compareStart!.getTime()
|
this._start,
|
||||||
: 0;
|
this._compareStart!
|
||||||
|
);
|
||||||
|
|
||||||
waterSources.forEach((source, idx) => {
|
waterSources.forEach((source, idx) => {
|
||||||
let prevStart: number | null = null;
|
let prevStart: number | null = null;
|
||||||
@ -234,10 +236,13 @@ export class HuiEnergyWaterGraphCard
|
|||||||
if (prevStart === point.start) {
|
if (prevStart === point.start) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const dataPoint = [point.start, point.change];
|
const dataPoint: (Date | string | number)[] = [
|
||||||
|
point.start,
|
||||||
|
point.change,
|
||||||
|
];
|
||||||
if (compare) {
|
if (compare) {
|
||||||
dataPoint[2] = dataPoint[0];
|
dataPoint[2] = dataPoint[0];
|
||||||
dataPoint[0] += compareOffset;
|
dataPoint[0] = compareTransform(new Date(point.start));
|
||||||
}
|
}
|
||||||
waterConsumptionData.push(dataPoint);
|
waterConsumptionData.push(dataPoint);
|
||||||
prevStart = point.start;
|
prevStart = point.start;
|
||||||
|
@ -65,7 +65,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
|||||||
return {
|
return {
|
||||||
columns: 12,
|
columns: 12,
|
||||||
min_columns: 6,
|
min_columns: 6,
|
||||||
min_rows: this._config?.entities?.length || 1,
|
min_rows: 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,7 +244,8 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
|||||||
})}`;
|
})}`;
|
||||||
|
|
||||||
const columns = this._config.grid_options?.columns ?? 12;
|
const columns = this._config.grid_options?.columns ?? 12;
|
||||||
const narrow = Number.isNaN(columns) || Number(columns) < 12;
|
const narrow = typeof columns === "number" && columns <= 12;
|
||||||
|
const hasFixedHeight = typeof this._config.grid_options?.rows === "number";
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-card>
|
<ha-card>
|
||||||
@ -259,6 +260,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
|||||||
<div
|
<div
|
||||||
class="content ${classMap({
|
class="content ${classMap({
|
||||||
"has-header": !!this._config.title,
|
"has-header": !!this._config.title,
|
||||||
|
"has-rows": !!this._config.grid_options?.rows,
|
||||||
})}"
|
})}"
|
||||||
>
|
>
|
||||||
${this._error
|
${this._error
|
||||||
@ -283,9 +285,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
|||||||
.minYAxis=${this._config.min_y_axis}
|
.minYAxis=${this._config.min_y_axis}
|
||||||
.maxYAxis=${this._config.max_y_axis}
|
.maxYAxis=${this._config.max_y_axis}
|
||||||
.fitYData=${this._config.fit_y_data || false}
|
.fitYData=${this._config.fit_y_data || false}
|
||||||
.height=${this._config.grid_options?.rows
|
.height=${hasFixedHeight ? "100%" : undefined}
|
||||||
? "100%"
|
|
||||||
: undefined}
|
|
||||||
.narrow=${narrow}
|
.narrow=${narrow}
|
||||||
></state-history-charts>
|
></state-history-charts>
|
||||||
`}
|
`}
|
||||||
@ -303,6 +303,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
|||||||
.card-header {
|
.card-header {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
.card-header ha-icon-next {
|
.card-header ha-icon-next {
|
||||||
--mdc-icon-button-size: 24px;
|
--mdc-icon-button-size: 24px;
|
||||||
@ -310,7 +311,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
|||||||
color: var(--primary-text-color);
|
color: var(--primary-text-color);
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
padding: 16px;
|
padding: 0 16px 8px 16px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.has-header {
|
.has-header {
|
||||||
@ -318,6 +319,10 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
|||||||
}
|
}
|
||||||
state-history-charts {
|
state-history-charts {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
--timeline-top-margin: 16px;
|
||||||
|
}
|
||||||
|
.has-rows {
|
||||||
|
--chart-max-height: 100%;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,10 @@ import { customElement, property, state } from "lit/decorators";
|
|||||||
import { classMap } from "lit/directives/class-map";
|
import { classMap } from "lit/directives/class-map";
|
||||||
import "../../../components/ha-card";
|
import "../../../components/ha-card";
|
||||||
import { getEnergyDataCollection } from "../../../data/energy";
|
import { getEnergyDataCollection } from "../../../data/energy";
|
||||||
import { getSuggestedPeriod } from "./energy/common/energy-chart-options";
|
import {
|
||||||
|
getSuggestedMax,
|
||||||
|
getSuggestedPeriod,
|
||||||
|
} from "./energy/common/energy-chart-options";
|
||||||
import type {
|
import type {
|
||||||
Statistics,
|
Statistics,
|
||||||
StatisticsMetaData,
|
StatisticsMetaData,
|
||||||
@ -255,8 +258,13 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
|||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasFixedHeight = typeof this._config.grid_options?.rows === "number";
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-card .header=${this._config.title}>
|
<ha-card>
|
||||||
|
${this._config.title
|
||||||
|
? html`<h1 class="card-header">${this._config.title}</h1>`
|
||||||
|
: nothing}
|
||||||
<div
|
<div
|
||||||
class="content ${classMap({
|
class="content ${classMap({
|
||||||
"has-header": !!this._config.title,
|
"has-header": !!this._config.title,
|
||||||
@ -274,11 +282,20 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
|||||||
.unit=${this._unit}
|
.unit=${this._unit}
|
||||||
.minYAxis=${this._config.min_y_axis}
|
.minYAxis=${this._config.min_y_axis}
|
||||||
.maxYAxis=${this._config.max_y_axis}
|
.maxYAxis=${this._config.max_y_axis}
|
||||||
|
.startTime=${this._energyStart}
|
||||||
|
.endTime=${this._energyEnd && this._energyStart
|
||||||
|
? getSuggestedMax(
|
||||||
|
differenceInDays(this._energyEnd, this._energyStart),
|
||||||
|
this._energyEnd
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
.fitYData=${this._config.fit_y_data || false}
|
.fitYData=${this._config.fit_y_data || false}
|
||||||
.hideLegend=${this._config.hide_legend || false}
|
.hideLegend=${this._config.hide_legend || false}
|
||||||
.logarithmicScale=${this._config.logarithmic_scale || false}
|
.logarithmicScale=${this._config.logarithmic_scale || false}
|
||||||
.daysToShow=${this._config.days_to_show || DEFAULT_DAYS_TO_SHOW}
|
.daysToShow=${this._energyStart && this._energyEnd
|
||||||
.height=${this._config.grid_options?.rows ? "100%" : undefined}
|
? differenceInDays(this._energyEnd, this._energyStart)
|
||||||
|
: this._config.days_to_show || DEFAULT_DAYS_TO_SHOW}
|
||||||
|
.height=${hasFixedHeight ? "100%" : undefined}
|
||||||
></statistics-chart>
|
></statistics-chart>
|
||||||
</div>
|
</div>
|
||||||
</ha-card>
|
</ha-card>
|
||||||
@ -358,8 +375,12 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.card-header {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
.content {
|
.content {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
padding-top: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.has-header {
|
.has-header {
|
||||||
|
@ -246,7 +246,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
|
|||||||
.startDate=${this._startDate}
|
.startDate=${this._startDate}
|
||||||
.endDate=${this._endDate || new Date()}
|
.endDate=${this._endDate || new Date()}
|
||||||
.ranges=${this._ranges}
|
.ranges=${this._ranges}
|
||||||
@change=${this._dateRangeChanged}
|
@value-changed=${this._dateRangeChanged}
|
||||||
minimal
|
minimal
|
||||||
></ha-date-range-picker>
|
></ha-date-range-picker>
|
||||||
</div>
|
</div>
|
||||||
@ -346,7 +346,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
|
|||||||
private _dateRangeChanged(ev) {
|
private _dateRangeChanged(ev) {
|
||||||
const weekStartsOn = firstWeekdayIndex(this.hass.locale);
|
const weekStartsOn = firstWeekdayIndex(this.hass.locale);
|
||||||
this._startDate = calcDate(
|
this._startDate = calcDate(
|
||||||
ev.detail.startDate,
|
ev.detail.value.startDate,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
this.hass.locale,
|
this.hass.locale,
|
||||||
this.hass.config,
|
this.hass.config,
|
||||||
@ -355,7 +355,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
this._endDate = calcDate(
|
this._endDate = calcDate(
|
||||||
ev.detail.endDate,
|
ev.detail.value.endDate,
|
||||||
endOfDay,
|
endOfDay,
|
||||||
this.hass.locale,
|
this.hass.locale,
|
||||||
this.hass.config,
|
this.hass.config,
|
||||||
|
@ -201,9 +201,7 @@ export class HuiGenericEntityRow extends LitElement {
|
|||||||
padding-inline-end: 8px;
|
padding-inline-end: 8px;
|
||||||
flex: 1 1 30%;
|
flex: 1 1 30%;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
display: flex;
|
align-content: center;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
.info,
|
.info,
|
||||||
.info > * {
|
.info > * {
|
||||||
@ -238,8 +236,7 @@ export class HuiGenericEntityRow extends LitElement {
|
|||||||
.value {
|
.value {
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
display: flex;
|
align-content: center;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -462,7 +462,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _openMoreInfo() {
|
private _openMoreInfo() {
|
||||||
if (this._browserPlayer) {
|
if (this.entityId === BROWSER_PLAYER) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fireEvent(this, "hass-more-info", { entityId: this.entityId });
|
fireEvent(this, "hass-more-info", { entityId: this.entityId });
|
||||||
|
@ -29,21 +29,11 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
// !__DEV__ &&
|
(!__DEV__ &&
|
||||||
ev.message.includes("ResizeObserver loop limit exceeded") ||
|
ev.message.includes("ResizeObserver loop limit exceeded")) ||
|
||||||
ev.message.includes(
|
ev.message.includes(
|
||||||
"ResizeObserver loop completed with undelivered notifications"
|
"ResizeObserver loop completed with undelivered notifications"
|
||||||
) ||
|
)
|
||||||
(ev.error.stack.includes("echarts") &&
|
|
||||||
(ev.message.includes(
|
|
||||||
"Cannot read properties of undefined (reading 'hostedBy')"
|
|
||||||
) ||
|
|
||||||
ev.message.includes(
|
|
||||||
"Cannot read properties of undefined (reading 'scale')"
|
|
||||||
) ||
|
|
||||||
ev.message.includes(
|
|
||||||
"Cannot read properties of null (reading 'innerHTML')"
|
|
||||||
)))
|
|
||||||
) {
|
) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopImmediatePropagation();
|
ev.stopImmediatePropagation();
|
||||||
|
@ -2223,7 +2223,8 @@
|
|||||||
"backup_type": "Type",
|
"backup_type": "Type",
|
||||||
"type": {
|
"type": {
|
||||||
"manual": "Manual",
|
"manual": "Manual",
|
||||||
"automatic": "Automatic"
|
"automatic": "Automatic",
|
||||||
|
"addon_update": "Add-on update"
|
||||||
},
|
},
|
||||||
"locations": "Locations",
|
"locations": "Locations",
|
||||||
"create": {
|
"create": {
|
||||||
@ -2392,17 +2393,23 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"decryption_unsupported_title": "Decryption unsupported",
|
"decryption_unsupported_title": "Decryption unsupported",
|
||||||
"decryption_unsupported": "Decryption is not supported for this backup. The downloaded backup will remain encrypted and can't be opened. To restore it, you will need the encryption key.",
|
"decryption_unsupported": "Decryption is not supported for this backup. The downloaded backup will remain encrypted and can't be opened. To restore it, you will need the encryption key.",
|
||||||
"incorrect_entered_encryption_key": "The entered encryption key was incorrect, try again or download the encrypted backup. The encrypted backup can't be opened. To restore it, you will need the encryption key.",
|
|
||||||
"download_encrypted": "Download encrypted",
|
|
||||||
"incorrect_current_encryption_key": "This backup is encrypted with a different encryption key than the current one, please enter the encryption key of this backup.",
|
|
||||||
"error_check_title": "Error checking backup",
|
"error_check_title": "Error checking backup",
|
||||||
"error_check_description": "An error occurred while checking the backup, please try again. Error message: {error}"
|
"error_check_description": "An error occurred while checking the backup, please try again. Error message: {error}",
|
||||||
|
"title": "Download backup",
|
||||||
|
"description": "This backup is encrypted with a different encryption key than the current one, please enter the encryption key of this backup.",
|
||||||
|
"download_backup_encrypted": "You can still {download_it_encrypted}. To restore it, you will need the encryption key.",
|
||||||
|
"download_it_encrypted": "download the backup encrypted",
|
||||||
|
"encryption_key": "Encryption key",
|
||||||
|
"incorrect_encryption_key": "Incorrect encryption key",
|
||||||
|
"decryption_not_supported": "Decryption not supported",
|
||||||
|
"download": "Download"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"cloud_agent_description": "Note: It stores only the most recent backup, regardless of your retention settings, with a maximum size of 5 GB.",
|
"cloud_agent_description": "Note: It stores only the most recent backup, regardless of your retention settings, with a maximum size of 5 GB.",
|
||||||
"cloud_agent_no_subcription": "You currently do not have an active Home Assistant Cloud subscription.",
|
"cloud_agent_no_subcription": "You currently do not have an active Home Assistant Cloud subscription.",
|
||||||
"network_mount_agent_description": "Network storage",
|
"network_mount_agent_description": "Network storage",
|
||||||
|
"unavailable_agents": "Unavailable locations",
|
||||||
"no_agents": "No locations configured",
|
"no_agents": "No locations configured",
|
||||||
"encryption_turned_off": "Encryption turned off",
|
"encryption_turned_off": "Encryption turned off",
|
||||||
"local_agent": "This system"
|
"local_agent": "This system"
|
||||||
@ -2560,6 +2567,7 @@
|
|||||||
"title": "My backups",
|
"title": "My backups",
|
||||||
"automatic": "{count} automatic {count, plural,\n one {backup}\n other {backups}\n}",
|
"automatic": "{count} automatic {count, plural,\n one {backup}\n other {backups}\n}",
|
||||||
"manual": "{count} manual {count, plural,\n one {backup}\n other {backups}\n}",
|
"manual": "{count} manual {count, plural,\n one {backup}\n other {backups}\n}",
|
||||||
|
"addon_update": "{count} add-on update {count, plural,\n one {backup}\n other {backups}\n}",
|
||||||
"total_size": "{size} in total",
|
"total_size": "{size} in total",
|
||||||
"show_all": "Show all backups"
|
"show_all": "Show all backups"
|
||||||
},
|
},
|
||||||
@ -2678,19 +2686,19 @@
|
|||||||
"encryption": {
|
"encryption": {
|
||||||
"title": "Encryption",
|
"title": "Encryption",
|
||||||
"description": "All your backups are encrypted by default to keep your data private and secure.",
|
"description": "All your backups are encrypted by default to keep your data private and secure.",
|
||||||
"location_encrypted": "This location is encrypted",
|
"location_encrypted": "Backups made to this location will be encrypted",
|
||||||
"location_unencrypted": "This location is unencrypted",
|
"location_unencrypted": "Backups made to this location will be unencrypted",
|
||||||
"location_encrypted_description": "Your data private and secure by securing it with your encryption key.",
|
"location_encrypted_description": "Your data is private and secure by encrypting backups with your encryption key.",
|
||||||
"location_encrypted_cloud_description": "Home Assistant Cloud is the privacy-focused cloud. This is why it will only accept encrypted backups and why we don’t store your encryption key.",
|
"location_encrypted_cloud_description": "Home Assistant Cloud is the privacy-focused cloud. This is why it will only accept encrypted backups and why we don’t store your encryption key.",
|
||||||
"location_encrypted_cloud_learn_more": "Learn more",
|
"location_encrypted_cloud_learn_more": "Learn more",
|
||||||
"location_unencrypted_description": "Please keep your backups private and secure.",
|
"location_unencrypted_description": "Please keep your backups private and secure.",
|
||||||
"encryption_turn_on": "Turn on",
|
"encryption_turn_on": "Turn on",
|
||||||
"encryption_turn_off": "Turn off",
|
"encryption_turn_off": "Turn off",
|
||||||
"encryption_turn_off_confirm_title": "Turn encryption off?",
|
"encryption_turn_off_confirm_title": "Turn encryption off?",
|
||||||
"encryption_turn_off_confirm_text": "All your next backups will not be encrypted for this location. Please keep your backups private and secure.",
|
"encryption_turn_off_confirm_text": "After confirming, backups created will be unencrypted for this location. Please ensure your backups remain private and secure.",
|
||||||
"encryption_turn_off_confirm_action": "Turn encryption off",
|
"encryption_turn_off_confirm_action": "Turn encryption off",
|
||||||
"warning_encryption_turn_off": "Encryption turned off",
|
"warning_encryption_turn_off": "Encryption turned off",
|
||||||
"warning_encryption_turn_off_description": "All your next backups will not be encrypted."
|
"warning_encryption_turn_off_description": "Backups will be unencrypted."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -4584,6 +4592,7 @@
|
|||||||
"account_created": "Account created! Check your email for instructions on how to activate your account."
|
"account_created": "Account created! Check your email for instructions on how to activate your account."
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
|
"download_support_package": "Download support package",
|
||||||
"reset_cloud_data": "Reset cloud data",
|
"reset_cloud_data": "Reset cloud data",
|
||||||
"reset_data_confirm_title": "Reset cloud data?",
|
"reset_data_confirm_title": "Reset cloud data?",
|
||||||
"reset_data_confirm_text": "This will reset all your cloud settings. This includes your remote connection, Google Assistant and Amazon Alexa integrations. This action cannot be undone.",
|
"reset_data_confirm_text": "This will reset all your cloud settings. This includes your remote connection, Google Assistant and Amazon Alexa integrations. This action cannot be undone.",
|
||||||
@ -5330,6 +5339,8 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"rssi": "RSSI",
|
"rssi": "RSSI",
|
||||||
|
"source_address": "Source address",
|
||||||
|
"device": "Device",
|
||||||
"device_information": "Device information",
|
"device_information": "Device information",
|
||||||
"advertisement_data": "Advertisement data",
|
"advertisement_data": "Advertisement data",
|
||||||
"manufacturer_data": "Manufacturer data",
|
"manufacturer_data": "Manufacturer data",
|
||||||
|
@ -21,5 +21,11 @@ export function measureTextWidth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.font = `${fontSize}px ${fontFamily}`;
|
context.font = `${fontSize}px ${fontFamily}`;
|
||||||
return Math.ceil(context.measureText(text).width);
|
const textMetrics = context.measureText(text);
|
||||||
|
return Math.ceil(
|
||||||
|
Math.max(
|
||||||
|
textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft,
|
||||||
|
textMetrics.width
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user