diff --git a/pyproject.toml b/pyproject.toml
index 323d65a092..193a84b4b7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
-version = "20250129.0"
+version = "20250205.0"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"
diff --git a/src/common/color/colors.ts b/src/common/color/colors.ts
index a661562ead..aace900bbd 100644
--- a/src/common/color/colors.ts
+++ b/src/common/color/colors.ts
@@ -1,3 +1,4 @@
+import memoizeOne from "memoize-one";
import { theme2hex } from "./convert-color";
export const COLORS = [
@@ -74,3 +75,12 @@ export function getGraphColorByIndex(
getColorByIndex(index);
return theme2hex(themeColor);
}
+
+export const getAllGraphColors = memoizeOne(
+ (style: CSSStyleDeclaration) =>
+ COLORS.map((_color, index) => getGraphColorByIndex(index, style)),
+ (newArgs: [CSSStyleDeclaration], lastArgs: [CSSStyleDeclaration]) =>
+ // this is not ideal, but we need to memoize the colors
+ newArgs[0].getPropertyValue("--graph-color-1") ===
+ lastArgs[0].getPropertyValue("--graph-color-1")
+);
diff --git a/src/components/chart/axis-label.ts b/src/components/chart/axis-label.ts
index d779269e33..1f8f9a8154 100644
--- a/src/components/chart/axis-label.ts
+++ b/src/components/chart/axis-label.ts
@@ -1,5 +1,4 @@
import type { HassConfig } from "home-assistant-js-websocket";
-import type { XAXisOption } from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../data/translation";
import {
formatDateMonth,
@@ -7,56 +6,46 @@ import {
formatDateVeryShort,
formatDateWeekdayShort,
} 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,
config: HassConfig,
- dayDifference = 0
+ minutesDifference: number
) {
- return (value: number | Date) => {
- const date = new Date(value);
- if (dayDifference > 88) {
- return date.getMonth() === 0
- ? `{bold|${formatDateMonthYear(date, locale, config)}}`
- : formatDateMonth(date, locale, config);
- }
- if (dayDifference > 35) {
- return date.getDate() === 1
- ? `{bold|${formatDateVeryShort(date, locale, config)}}`
- : formatDateVeryShort(date, locale, config);
- }
- if (dayDifference > 7) {
- const label = formatDateVeryShort(date, locale, config);
- return date.getDate() === 1 ? `{bold|${label}}` : label;
- }
- if (dayDifference > 2) {
- return formatDateWeekdayShort(date, locale, config);
- }
+ const dayDifference = minutesDifference / 60 / 24;
+ const date = new Date(value);
+ if (dayDifference > 88) {
+ return date.getMonth() === 0
+ ? `{bold|${formatDateMonthYear(date, locale, config)}}`
+ : formatDateMonth(date, locale, config);
+ }
+ if (dayDifference > 35) {
+ return date.getDate() === 1
+ ? `{bold|${formatDateVeryShort(date, locale, config)}}`
+ : formatDateVeryShort(date, locale, config);
+ }
+ if (dayDifference > 7) {
+ const label = formatDateVeryShort(date, locale, config);
+ return date.getDate() === 1 ? `{bold|${label}}` : label;
+ }
+ if (dayDifference > 2) {
+ return formatDateWeekdayShort(date, locale, config);
+ }
+ if (minutesDifference && minutesDifference < 5) {
+ return formatTimeWithSeconds(date, locale, config);
+ }
+ if (
+ date.getHours() === 0 &&
+ date.getMinutes() === 0 &&
+ date.getSeconds() === 0
+ ) {
// show only date for the beginning of the day
- if (
- date.getHours() === 0 &&
- date.getMinutes() === 0 &&
- date.getSeconds() === 0
- ) {
- return `{bold|${formatDateVeryShort(date, locale, config)}}`;
- }
- return formatTime(date, locale, config);
- };
-}
-
-export function getTimeAxisLabelConfig(
- locale: FrontendLocaleData,
- config: HassConfig,
- dayDifference?: number
-): XAXisOption["axisLabel"] {
- return {
- formatter: getLabelFormatter(locale, config, dayDifference),
- rich: {
- bold: {
- fontWeight: "bold",
- },
- },
- hideOverlap: true,
- };
+ return `{bold|${formatDateVeryShort(date, locale, config)}}`;
+ }
+ return formatTime(date, locale, config);
}
diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts
index 42450c8bb8..4988ff75c8 100644
--- a/src/components/chart/ha-chart-base.ts
+++ b/src/components/chart/ha-chart-base.ts
@@ -1,25 +1,29 @@
-import type { PropertyValues } from "lit";
-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 { consume } from "@lit-labs/context";
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 {
ECElementEvent,
XAXisOption,
YAXisOption,
} 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 { 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 { isMac } from "../../util/is_mac";
import "../ha-icon-button";
-import type { ECOption } from "../../resources/echarts";
-import { listenMediaQuery } from "../../common/dom/media_query";
-import type { Themes } from "../../data/ws-themes";
-import { themesContext } from "../../data/context";
+import { formatTimeLabel } from "./axis-label";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
@@ -44,6 +48,10 @@ export class HaChartBase extends LitElement {
@state() private _isZoomed = false;
+ @state() private _zoomRatio = 1;
+
+ @state() private _minutesDifference = 24 * 60;
+
private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window;
@@ -135,16 +143,7 @@ export class HaChartBase extends LitElement {
this.chart.setOption(this._createOptions(), {
lazyUpdate: true,
// if we replace the whole object, it will reset the dataZoom
- replaceMerge: [
- "xAxis",
- "yAxis",
- "dataZoom",
- "dataset",
- "tooltip",
- "legend",
- "grid",
- "visualMap",
- ],
+ replaceMerge: ["grid"],
});
}
}
@@ -152,7 +151,10 @@ export class HaChartBase extends LitElement {
protected render() {
return html`
+ formatTimeLabel(
+ value,
+ this.hass.locale,
+ this.hass.config,
+ this._minutesDifference * this._zoomRatio
+ );
+
private async _setupChart() {
if (this._loading) return;
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
@@ -183,10 +193,9 @@ export class HaChartBase extends LitElement {
}
const echarts = (await import("../../resources/echarts")).default;
- this.chart = echarts.init(
- container,
- this._themes.darkMode ? "dark" : "light"
- );
+ echarts.registerTheme("custom", this._createTheme());
+
+ this.chart = echarts.init(container, "custom");
this.chart.on("legendselectchanged", (params: any) => {
if (this.externalHidden) {
const isSelected = params.selected[params.name];
@@ -200,6 +209,7 @@ export class HaChartBase extends LitElement {
this.chart.on("datazoom", (e: any) => {
const { start, end } = e.batch?.[0] ?? e;
this._isZoomed = start !== 0 || end !== 100;
+ this._zoomRatio = (end - start) / 100;
});
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
@@ -237,24 +247,60 @@ export class HaChartBase extends LitElement {
}
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 = {
- backgroundColor: "transparent",
animation: !this._reducedMotion,
- darkMode,
+ darkMode: this._themes.darkMode ?? false,
aria: {
show: true,
},
dataZoom: this._getDataZoomConfig(),
...this.options,
- legend: this.options?.legend
- ? {
- // we should create our own theme but this is a quick fix for now
- inactiveColor: darkMode ? "#444" : "#ccc",
- ...this.options.legend,
- }
- : undefined,
+ xAxis,
};
const isMobile = window.matchMedia(
@@ -268,18 +314,206 @@ export class HaChartBase extends LitElement {
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
+ tooltip.triggerOn = "click";
});
options.tooltip = tooltips;
}
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() {
- return Math.max(this.clientWidth / 2, 400);
+ return Math.max(this.clientWidth / 2, 200);
}
private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
+ this._modifierPressed = false;
}
private _handleWheel(e: WheelEvent) {
@@ -302,10 +536,11 @@ export class HaChartBase extends LitElement {
:host {
display: block;
position: relative;
+ letter-spacing: normal;
}
.chart-container {
position: relative;
- max-height: var(--chart-max-height, 400px);
+ max-height: var(--chart-max-height, 350px);
}
.chart {
width: 100%;
@@ -321,6 +556,9 @@ export class HaChartBase extends LitElement {
color: var(--primary-color);
border: 1px solid var(--divider-color);
}
+ .has-legend .zoom-reset {
+ top: 64px;
+ }
`;
}
diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts
index 9ba683cdf4..d268bd5d7c 100644
--- a/src/components/chart/state-history-chart-line.ts
+++ b/src/components/chart/state-history-chart-line.ts
@@ -4,7 +4,6 @@ import { property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
import type { YAXisOption } from "echarts/types/dist/shared";
-import { differenceInDays } from "date-fns";
import { styleMap } from "lit/directives/style-map";
import { getGraphColorByIndex } from "../../common/color/colors";
import { computeRTL } from "../../common/util/compute_rtl";
@@ -18,10 +17,10 @@ import {
getNumberFormatOptions,
formatNumber,
} from "../../common/number/format_number";
-import { getTimeAxisLabelConfig } from "./axis-label";
import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
+import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
@@ -72,6 +71,8 @@ export class StateHistoryChartLine extends LitElement {
@state() private _chartOptions?: ECOption;
+ private _hiddenStats = new Set
();
+
@state() private _yWidth = 25;
private _chartTime: Date = new Date();
@@ -84,49 +85,104 @@ export class StateHistoryChartLine extends LitElement {
.options=${this._chartOptions}
.height=${this.height}
style=${styleMap({ height: this.height })}
+ external-hidden
+ @dataset-hidden=${this._datasetHidden}
+ @dataset-unhidden=${this._datasetUnhidden}
>
`;
}
- private _renderTooltip(params) {
- return params
- .map((param, index: number) => {
- let value = `${formatNumber(
- param.value[1] as number,
- this.hass.locale,
- getNumberFormatOptions(
- undefined,
- this.hass.entities[this._entityIds[param.seriesIndex]]
- )
- )} ${this.unit}`;
- const dataIndex = this._datasetToDataIndex[param.seriesIndex];
- const data = this.data[dataIndex];
- if (data.statistics && data.statistics.length > 0) {
- value += " ";
- 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;
+ private _renderTooltip(params: any) {
+ const time = params[0].axisValue;
+ const title =
+ formatDateTimeWithSeconds(
+ new Date(time),
+ this.hass.locale,
+ this.hass.config
+ ) + " ";
+ const datapoints: Record[] = [];
+ this._chartData.forEach((dataset, index) => {
+ if (
+ dataset.tooltip?.show === false ||
+ this._hiddenStats.has(dataset.name as string)
+ )
+ return;
+ const param = params.find(
+ (p: Record) => p.seriesIndex === index
+ );
+ if (param) {
+ datapoints.push(param);
+ return;
+ }
+ // If the datapoint is not found, we need to find the last datapoint before the current time
+ let lastData;
+ const data = dataset.data || [];
+ for (let i = data.length - 1; i >= 0; i--) {
+ const point = data[i];
+ if (point && point[0] <= time && point[1]) {
+ lastData = point;
+ break;
}
+ }
+ if (!lastData) return;
+ datapoints.push({
+ seriesName: dataset.name,
+ seriesIndex: index,
+ value: lastData,
+ // HTML copied from echarts. May change based on options
+ marker: ` `,
+ });
+ });
+ const unit = this.unit
+ ? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
+ : "";
- const time =
- index === 0
- ? formatDateTimeWithSeconds(
- new Date(param.value[0]),
+ return (
+ title +
+ datapoints
+ .map((param) => {
+ const entityId = this._entityIds[param.seriesIndex];
+ const stateObj = this.hass.states[entityId];
+ const entry = this.hass.entities[entityId];
+ const stateValue = String(param.value[1]);
+ let value = stateObj
+ ? this.hass.formatEntityState(stateObj, stateValue)
+ : `${formatNumber(
+ stateValue,
this.hass.locale,
- this.hass.config
- ) + " "
- : "";
- return `${time}${param.marker} ${param.seriesName}: ${value}
- `;
- })
- .join(" ");
+ getNumberFormatOptions(undefined, entry)
+ )}${unit}`;
+ const dataIndex = this._datasetToDataIndex[param.seriesIndex];
+ const data = this.data[dataIndex];
+ if (data.statistics && data.statistics.length > 0) {
+ value += " ";
+ 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(" ")
+ );
+ }
+
+ private _datasetHidden(ev: CustomEvent) {
+ this._hiddenStats.add(ev.detail.name);
+ }
+
+ private _datasetUnhidden(ev: CustomEvent) {
+ this._hiddenStats.delete(ev.detail.name);
}
public willUpdate(changedProps: PropertyValues) {
@@ -156,49 +212,44 @@ export class StateHistoryChartLine extends LitElement {
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
) {
- const dayDifference = differenceInDays(this.endTime, this.startTime);
const rtl = computeRTL(this.hass);
- const splitLineStyle = this.hass.themes?.darkMode
- ? { opacity: 0.15 }
- : {};
+ let minYAxis: number | ((values: { min: number }) => number) | undefined =
+ this.minYAxis;
+ let maxYAxis: number | ((values: { max: number }) => number) | undefined =
+ this.maxYAxis;
+ if (typeof minYAxis === "number") {
+ if (this.fitYData) {
+ minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
+ }
+ } else if (this.logarithmicScale) {
+ minYAxis = ({ min }) => (min > 0 ? min * 0.95 : min * 1.05);
+ }
+ if (typeof maxYAxis === "number") {
+ if (this.fitYData) {
+ maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
+ }
+ } else if (this.logarithmicScale) {
+ maxYAxis = ({ max }) => (max > 0 ? max * 1.05 : max * 0.95);
+ }
this._chartOptions = {
xAxis: {
type: "time",
min: this.startTime,
max: this.endTime,
- axisLabel: getTimeAxisLabelConfig(
- this.hass.locale,
- this.hass.config,
- dayDifference
- ),
- axisLine: {
- show: false,
- },
- splitLine: {
- show: true,
- lineStyle: splitLineStyle,
- },
- minInterval:
- dayDifference >= 89 // quarter
- ? 28 * 3600 * 24 * 1000
- : dayDifference > 2
- ? 3600 * 24 * 1000
- : undefined,
},
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
- min: this.fitYData ? this.minYAxis : undefined,
- max: this.fitYData ? this.maxYAxis : undefined,
+ min: this._clampYAxis(minYAxis),
+ max: this._clampYAxis(maxYAxis),
position: rtl ? "right" : "left",
scale: true,
nameGap: 2,
nameTextStyle: {
align: "left",
},
- splitLine: {
- show: true,
- lineStyle: splitLineStyle,
+ axisLine: {
+ show: false,
},
axisLabel: {
margin: 5,
@@ -218,6 +269,8 @@ export class StateHistoryChartLine extends LitElement {
} as YAXisOption,
legend: {
show: this.showNames,
+ type: "scroll",
+ animationDurationUpdate: 400,
icon: "circle",
padding: [20, 0],
},
@@ -307,13 +360,18 @@ export class StateHistoryChartLine extends LitElement {
prevValues = datavalues;
};
- const addDataSet = (nameY: string, color?: string, fill = false) => {
+ const addDataSet = (
+ id: string,
+ nameY: string,
+ color?: string,
+ fill = false
+ ) => {
if (!color) {
color = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
}
data.push({
- id: nameY,
+ id,
data: [],
type: "line",
cursor: "default",
@@ -321,6 +379,7 @@ export class StateHistoryChartLine extends LitElement {
color,
symbol: "circle",
step: "end",
+ animationDurationUpdate: 0,
symbolSize: 1,
lineStyle: {
width: fill ? 0 : 1.5,
@@ -375,13 +434,23 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.target_temp_low
);
addDataSet(
- `${this.hass.localize("ui.card.climate.current_temperature", {
- name: name,
- })}`
+ states.entity_id + "-current_temperature",
+ this.showNames
+ ? this.hass.localize("ui.card.climate.current_temperature", {
+ name: name,
+ })
+ : this.hass.localize(
+ "component.climate.entity_component._.state_attributes.current_temperature.name"
+ )
);
if (hasHeat) {
addDataSet(
- `${this.hass.localize("ui.card.climate.heating", { name: name })}`,
+ states.entity_id + "-heating",
+ this.showNames
+ ? this.hass.localize("ui.card.climate.heating", { name: name })
+ : this.hass.localize(
+ "component.climate.entity_component._.state_attributes.hvac_action.state.heating"
+ ),
computedStyles.getPropertyValue("--state-climate-heat-color"),
true
);
@@ -390,7 +459,12 @@ export class StateHistoryChartLine extends LitElement {
}
if (hasCool) {
addDataSet(
- `${this.hass.localize("ui.card.climate.cooling", { name: name })}`,
+ states.entity_id + "-cooling",
+ this.showNames
+ ? this.hass.localize("ui.card.climate.cooling", { name: name })
+ : this.hass.localize(
+ "component.climate.entity_component._.state_attributes.hvac_action.state.cooling"
+ ),
computedStyles.getPropertyValue("--state-climate-cool-color"),
true
);
@@ -400,22 +474,40 @@ export class StateHistoryChartLine extends LitElement {
if (hasTargetRange) {
addDataSet(
- `${this.hass.localize("ui.card.climate.target_temperature_mode", {
- name: name,
- mode: this.hass.localize("ui.card.climate.high"),
- })}`
+ states.entity_id + "-target_temperature_mode",
+ this.showNames
+ ? this.hass.localize("ui.card.climate.target_temperature_mode", {
+ name: name,
+ mode: this.hass.localize("ui.card.climate.high"),
+ })
+ : this.hass.localize(
+ "component.climate.entity_component._.state_attributes.target_temp_high.name"
+ )
);
addDataSet(
- `${this.hass.localize("ui.card.climate.target_temperature_mode", {
- name: name,
- mode: this.hass.localize("ui.card.climate.low"),
- })}`
+ states.entity_id + "-target_temperature_mode_low",
+ this.showNames
+ ? this.hass.localize("ui.card.climate.target_temperature_mode", {
+ name: name,
+ mode: this.hass.localize("ui.card.climate.low"),
+ })
+ : this.hass.localize(
+ "component.climate.entity_component._.state_attributes.target_temp_low.name"
+ )
);
} else {
addDataSet(
- `${this.hass.localize("ui.card.climate.target_temperature_entity", {
- name: name,
- })}`
+ states.entity_id + "-target_temperature",
+ this.showNames
+ ? this.hass.localize(
+ "ui.card.climate.target_temperature_entity",
+ {
+ name: name,
+ }
+ )
+ : this.hass.localize(
+ "component.climate.entity_component._.state_attributes.temperature.name"
+ )
);
}
@@ -468,19 +560,29 @@ export class StateHistoryChartLine extends LitElement {
);
addDataSet(
- `${this.hass.localize("ui.card.humidifier.target_humidity_entity", {
- name: name,
- })}`
+ states.entity_id + "-target_humidity",
+ this.showNames
+ ? this.hass.localize("ui.card.humidifier.target_humidity_entity", {
+ name: name,
+ })
+ : this.hass.localize(
+ "component.humidifier.entity_component._.state_attributes.humidity.name"
+ )
);
if (hasCurrent) {
addDataSet(
- `${this.hass.localize(
- "ui.card.humidifier.current_humidity_entity",
- {
- name: name,
- }
- )}`
+ states.entity_id + "-current_humidity",
+ this.showNames
+ ? this.hass.localize(
+ "ui.card.humidifier.current_humidity_entity",
+ {
+ name: name,
+ }
+ )
+ : this.hass.localize(
+ "component.humidifier.entity_component._.state_attributes.current_humidity.name"
+ )
);
}
@@ -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 (hasHumidifying) {
addDataSet(
- `${this.hass.localize("ui.card.humidifier.humidifying", {
- name: name,
- })}`,
+ states.entity_id + "-humidifying",
+ this.showNames
+ ? this.hass.localize("ui.card.humidifier.humidifying", {
+ name: name,
+ })
+ : this.hass.localize(
+ "component.humidifier.entity_component._.state_attributes.action.state.humidifying"
+ ),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else if (hasDrying) {
addDataSet(
- `${this.hass.localize("ui.card.humidifier.drying", {
- name: name,
- })}`,
+ states.entity_id + "-drying",
+ this.showNames
+ ? this.hass.localize("ui.card.humidifier.drying", {
+ name: name,
+ })
+ : this.hass.localize(
+ "component.humidifier.entity_component._.state_attributes.action.state.drying"
+ ),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else {
addDataSet(
- `${this.hass.localize("ui.card.humidifier.on_entity", {
- name: name,
- })}`,
+ states.entity_id + "-on",
+ this.showNames
+ ? this.hass.localize("ui.card.humidifier.on_entity", {
+ name: name,
+ })
+ : this.hass.localize(
+ "component.humidifier.entity_component._.state.on"
+ ),
undefined,
true
);
@@ -539,7 +656,7 @@ export class StateHistoryChartLine extends LitElement {
pushData(new Date(entityState.last_changed), series);
});
} else {
- addDataSet(name);
+ addDataSet(states.entity_id, name);
let lastValue: number;
let lastDate: Date;
@@ -609,6 +726,19 @@ export class StateHistoryChartLine extends LitElement {
this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex;
}
+
+ private _clampYAxis(value?: number | ((values: any) => number)) {
+ if (this.logarithmicScale) {
+ // log(0) is -Infinity, so we need to set a minimum value
+ if (typeof value === "number") {
+ return Math.max(value, 0.1);
+ }
+ if (typeof value === "function") {
+ return (values: any) => Math.max(value(values), 0.1);
+ }
+ }
+ return value;
+ }
}
customElements.define("state-history-chart-line", StateHistoryChartLine);
diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts
index 910b4cd8d3..89a59a7513 100644
--- a/src/components/chart/state-history-chart-timeline.ts
+++ b/src/components/chart/state-history-chart-timeline.ts
@@ -8,7 +8,6 @@ import type {
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
-import { differenceInDays } from "date-fns";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration";
import { computeRTL } from "../../common/util/compute_rtl";
@@ -22,7 +21,6 @@ import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
-import { getTimeAxisLabelConfig } from "./axis-label";
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@@ -67,7 +65,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
- .data=${this._chartData}
+ .data=${this._chartData as ECOption["series"]}
@chart-click=${this._handleChartClick}
>
`;
@@ -129,10 +127,12 @@ export class StateHistoryChartTimeline extends LitElement {
private _renderTooltip: TooltipFormatterCallback =
(params: TooltipPositionCallbackParams) => {
- const { value, name, marker } = Array.isArray(params)
+ const { value, name, marker, seriesName } = Array.isArray(params)
? params[0]
: params;
- const title = `${value![0]} `;
+ const title = seriesName
+ ? `${seriesName} `
+ : "";
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
@@ -183,13 +183,12 @@ export class StateHistoryChartTimeline extends LitElement {
private _createOptions() {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
- const maxInternalLabelWidth = narrow ? 70 : 165;
+ const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(this.hass);
- const dayDifference = differenceInDays(this.endTime, this.startTime);
this._chartOptions = {
xAxis: {
type: "time",
@@ -197,21 +196,10 @@ export class StateHistoryChartTimeline extends LitElement {
max: this.endTime,
axisTick: {
show: true,
- lineStyle: {
- opacity: 0.4,
- },
},
- axisLabel: getTimeAxisLabelConfig(
- this.hass.locale,
- this.hass.config,
- dayDifference
- ),
- minInterval:
- dayDifference >= 89 // quarter
- ? 28 * 3600 * 24 * 1000
- : dayDifference > 2
- ? 3600 * 24 * 1000
- : undefined,
+ splitLine: {
+ show: false,
+ },
},
yAxis: {
type: "category",
@@ -226,14 +214,18 @@ export class StateHistoryChartTimeline extends LitElement {
},
axisLabel: {
show: showNames,
- width: labelWidth - labelMargin,
+ width: labelWidth,
overflow: "truncate",
margin: labelMargin,
- formatter: (label: string) => {
- const width = Math.min(
- measureTextWidth(label, 12) + labelMargin,
- maxInternalLabelWidth
- );
+ formatter: (id: string) => {
+ const label = this._chartData.find((d) => d.id === id)
+ ?.name as string;
+ const width = label
+ ? Math.min(
+ measureTextWidth(label, 12) + labelMargin,
+ maxInternalLabelWidth
+ )
+ : 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
@@ -278,8 +270,9 @@ export class StateHistoryChartTimeline extends LitElement {
let prevState: string | null = null;
let locState: string | null = null;
let prevLastChanged = startTime;
- const entityDisplay: string =
- names[stateInfo.entity_id] || stateInfo.name;
+ const entityDisplay: string = this.showNames
+ ? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
+ : "";
const dataRow: unknown[] = [];
stateInfo.data.forEach((entityState) => {
@@ -307,7 +300,7 @@ export class StateHistoryChartTimeline extends LitElement {
);
dataRow.push({
value: [
- entityDisplay,
+ stateInfo.entity_id,
prevLastChanged,
newLastChanged,
locState,
@@ -333,7 +326,7 @@ export class StateHistoryChartTimeline extends LitElement {
);
dataRow.push({
value: [
- entityDisplay,
+ stateInfo.entity_id,
prevLastChanged,
endTime,
locState,
@@ -346,9 +339,10 @@ export class StateHistoryChartTimeline extends LitElement {
});
}
datasets.push({
+ id: stateInfo.entity_id,
data: dataRow,
name: entityDisplay,
- dimensions: ["index", "start", "end", "name", "color", "textColor"],
+ dimensions: ["id", "start", "end", "name", "color", "textColor"],
type: "custom",
encode: {
x: [1, 2],
@@ -364,10 +358,10 @@ export class StateHistoryChartTimeline extends LitElement {
private _handleChartClick(e: CustomEvent): void {
if (e.detail.targetType === "axisLabel") {
- const dataset = this.data[e.detail.dataIndex];
+ const dataset = this._chartData[e.detail.dataIndex];
if (dataset) {
fireEvent(this, "hass-more-info", {
- entityId: dataset.entity_id,
+ entityId: dataset.id as string,
});
}
}
diff --git a/src/components/chart/state-history-charts.ts b/src/components/chart/state-history-charts.ts
index b909fc1d09..8deb9694a7 100644
--- a/src/components/chart/state-history-charts.ts
+++ b/src/components/chart/state-history-charts.ts
@@ -135,7 +135,7 @@ export class StateHistoryCharts extends LitElement {
return html``;
}
if (!Array.isArray(item)) {
- return html`
+ return html`
`;
}
- return html`
+ return html`
= {
mean: "mean",
@@ -56,6 +56,8 @@ export class StatisticsChart extends LitElement {
@property() public unit?: string;
+ @property({ attribute: false }) public startTime?: Date;
+
@property({ attribute: false }) public endTime?: Date;
@property({ attribute: false, type: Array })
@@ -124,7 +126,10 @@ export class StatisticsChart extends LitElement {
changedProps.has("fitYData") ||
changedProps.has("logarithmicScale") ||
changedProps.has("hideLegend") ||
- changedProps.has("_legendData")
+ changedProps.has("startTime") ||
+ changedProps.has("endTime") ||
+ changedProps.has("_legendData") ||
+ changedProps.has("_chartData")
) {
this._createOptions();
}
@@ -181,18 +186,31 @@ export class StatisticsChart extends LitElement {
this.requestUpdate("_hiddenStats");
}
- private _renderTooltip = (params: any) =>
- params
+ private _renderTooltip = (params: any) => {
+ const rendered: Record = {};
+ const unit = this.unit
+ ? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
+ : "";
+ return params
.map((param, index: number) => {
+ if (rendered[param.seriesName]) return "";
+ rendered[param.seriesName] = true;
+
+ const statisticId = this._statisticIds[param.seriesIndex];
+ const stateObj = this.hass.states[statisticId];
+ const entry = this.hass.entities[statisticId];
+ // max series can have 3 values, as the second value is the max-min to form a band
+ const rawValue = String(param.value[2] ?? param.value[1]);
+
+ const options = getNumberFormatOptions(stateObj, entry) ?? {
+ maximumFractionDigits: 2,
+ };
+
const value = `${formatNumber(
- // max series can have 3 values, as the second value is the max-min to form a band
- (param.value[2] ?? param.value[1]) as number,
+ rawValue,
this.hass.locale,
- getNumberFormatOptions(
- undefined,
- this.hass.entities[this._statisticIds[param.seriesIndex]]
- )
- )} ${this.unit}`;
+ options
+ )}${unit}`;
const time =
index === 0
@@ -202,36 +220,68 @@ export class StatisticsChart extends LitElement {
this.hass.config
) + " "
: "";
- return `${time}${param.marker} ${param.seriesName}: ${value}
- `;
+ return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
+ .filter(Boolean)
.join(" ");
+ };
private _createOptions() {
- const splitLineStyle = this.hass.themes?.darkMode ? { opacity: 0.15 } : {};
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 = {
- xAxis: {
- type: "time",
- axisLabel: getTimeAxisLabelConfig(
- this.hass.locale,
- this.hass.config,
- dayDifference
- ),
- axisLine: {
+ xAxis: [
+ {
+ type: "time",
+ min: startTime,
+ max: endTime,
+ },
+ {
+ type: "time",
show: false,
},
- splitLine: {
- show: true,
- lineStyle: splitLineStyle,
- },
- minInterval:
- dayDifference >= 89 // quarter
- ? 28 * 3600 * 24 * 1000
- : dayDifference > 2
- ? 3600 * 24 * 1000
- : undefined,
- },
+ ],
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
@@ -241,23 +291,24 @@ export class StatisticsChart extends LitElement {
},
position: computeRTL(this.hass) ? "right" : "left",
// @ts-ignore
- scale: this.chartType !== "bar",
- min: this.fitYData ? undefined : this.minYAxis,
- max: this.fitYData ? undefined : this.maxYAxis,
+ scale: true,
+ min: this._clampYAxis(minYAxis),
+ max: this._clampYAxis(maxYAxis),
splitLine: {
show: true,
- lineStyle: splitLineStyle,
},
},
legend: {
show: !this.hideLegend,
+ type: "scroll",
+ animationDurationUpdate: 400,
icon: "circle",
padding: [20, 0],
data: this._legendData,
},
grid: {
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
- left: 20,
+ left: 1,
right: 1,
bottom: 0,
containLabel: true,
@@ -318,6 +369,7 @@ export class StatisticsChart extends LitElement {
if (endTime > new Date()) {
endTime = new Date();
}
+ this.endTime = endTime;
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,
// 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([start, ...dataValues[i]!]);
+ d.data!.push(this._transformDataValue([start, ...dataValues[i]!]));
});
prevValues = dataValues;
prevEndTime = end;
@@ -421,9 +475,14 @@ export class StatisticsChart extends LitElement {
displayedLegend = displayedLegend || showLegend;
}
statTypes.push(type);
+ const borderColor =
+ band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color;
+ const backgroundColor = band ? color + "3F" : color + "7F";
const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`,
type: this.chartType,
+ smooth: this.chartType === "line" ? 0.4 : false,
+ smoothMonotone: "x",
cursor: "default",
data: [],
name: name
@@ -435,6 +494,7 @@ export class StatisticsChart extends LitElement {
),
symbol: "circle",
symbolSize: 0,
+ animationDurationUpdate: 0,
lineStyle: {
width: 1.5,
},
@@ -442,21 +502,16 @@ export class StatisticsChart extends LitElement {
this.chartType === "bar"
? {
borderRadius: [4, 4, 0, 0],
- borderColor:
- band && hasMean
- ? color + (this.hideLegend ? "00" : "7F")
- : color,
+ borderColor,
borderWidth: 1.5,
}
: undefined,
- color: band ? color + "3F" : color + "7F",
+ color: this.chartType === "bar" ? backgroundColor : borderColor,
};
if (band && this.chartType === "line") {
series.stack = `band-${statistic_id}`;
+ series.stackStrategy = "all";
(series as LineSeriesOption).symbol = "none";
- (series as LineSeriesOption).lineStyle = {
- opacity: 0,
- };
if (drawBands && type === "max") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
@@ -489,7 +544,7 @@ export class StatisticsChart extends LitElement {
}
} else if (type === "max" && this.chartType === "line") {
const max = stat.max || 0;
- val.push(max - (stat.min || 0));
+ val.push(Math.abs(max - (stat.min || 0)));
val.push(max);
} else {
val.push(stat[type] ?? null);
@@ -518,6 +573,7 @@ export class StatisticsChart extends LitElement {
color,
type: this.chartType,
data: [],
+ xAxisIndex: 1,
});
});
@@ -529,6 +585,26 @@ export class StatisticsChart extends LitElement {
this._statisticIds = statisticIds;
}
+ private _transformDataValue(val: [Date, ...(number | null)[]]) {
+ if (this.chartType === "bar" && val[1] && val[1] < 0) {
+ return { value: val, itemStyle: { borderRadius: [0, 0, 4, 4] } };
+ }
+ return val;
+ }
+
+ private _clampYAxis(value?: number | ((values: any) => number)) {
+ if (this.logarithmicScale) {
+ // log(0) is -Infinity, so we need to set a minimum value
+ if (typeof value === "number") {
+ return Math.max(value, 0.1);
+ }
+ if (typeof value === "function") {
+ return (values: any) => Math.max(value(values), 0.1);
+ }
+ }
+ return value;
+ }
+
static styles = css`
:host {
display: block;
diff --git a/src/components/ha-base-time-input.ts b/src/components/ha-base-time-input.ts
index 2936e8c96f..7b2a5b3525 100644
--- a/src/components/ha-base-time-input.ts
+++ b/src/components/ha-base-time-input.ts
@@ -329,15 +329,12 @@ export class HaBaseTimeInput extends LitElement {
:host([clearable]) {
position: relative;
}
- :host {
- display: block;
- }
.time-input-wrap-wrap {
display: flex;
}
.time-input-wrap {
display: flex;
- flex: 1;
+ flex: var(--time-input-flex, unset);
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
overflow: hidden;
position: relative;
diff --git a/src/components/ha-date-range-picker.ts b/src/components/ha-date-range-picker.ts
index 71273a66ef..7dfd0c7383 100644
--- a/src/components/ha-date-range-picker.ts
+++ b/src/components/ha-date-range-picker.ts
@@ -9,12 +9,13 @@ import {
endOfMonth,
endOfWeek,
endOfYear,
+ isThisYear,
startOfDay,
startOfMonth,
startOfWeek,
startOfYear,
- isThisYear,
} from "date-fns";
+import { fromZonedTime, toZonedTime } from "date-fns-tz";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -22,16 +23,18 @@ import { ifDefined } from "lit/directives/if-defined";
import { calcDate, shiftDateRange } from "../common/datetime/calc_date";
import { firstWeekdayIndex } from "../common/datetime/first_weekday";
import {
- formatShortDateTimeWithYear,
formatShortDateTime,
+ formatShortDateTimeWithYear,
} from "../common/datetime/format_date_time";
import { useAmPm } from "../common/datetime/use_am_pm";
+import { fireEvent } from "../common/dom/fire_event";
+import { TimeZone } from "../data/translation";
import type { HomeAssistant } from "../types";
import "./date-range-picker";
import "./ha-icon-button";
-import "./ha-textarea";
import "./ha-icon-button-next";
import "./ha-icon-button-prev";
+import "./ha-textarea";
export type DateRangePickerRanges = Record;
@@ -197,14 +200,15 @@ export class HaDateRangePicker extends LitElement {
?auto-apply=${this.autoApply}
time-picker=${this.timePicker}
twentyfour-hours=${this._hour24format}
- start-date=${this.startDate.toISOString()}
- end-date=${this.endDate.toISOString()}
+ start-date=${this._formatDate(this.startDate)}
+ end-date=${this._formatDate(this.endDate)}
?ranges=${this.ranges !== false}
opening-direction=${ifDefined(
this.openingDirection || this._calcedOpeningDirection
)}
first-day=${firstWeekdayIndex(this.hass.locale)}
language=${this.hass.locale.language}
+ @change=${this._handleChange}
>
`;
}
private async _fetchAssistConfiguration() {
- this._assistConfiguration = await fetchAssistSatelliteConfiguration(
- this.hass,
- this._findDomainEntityId(
- this._params!.deviceId,
- this.hass.entities,
- "assist_satellite"
- )!
- );
- return this._assistConfiguration;
+ try {
+ this._assistConfiguration = await fetchAssistSatelliteConfiguration(
+ this.hass,
+ this._findDomainEntityId(
+ this._params!.deviceId,
+ this.hass.entities,
+ "assist_satellite"
+ )!
+ );
+ } catch (err: any) {
+ this._error = err.message;
+ }
}
private _goToPreviousStep() {
@@ -293,6 +302,10 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
.skip-btn {
margin-top: 6px;
}
+ ha-alert {
+ margin: 24px;
+ display: block;
+ }
`,
];
}
diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-success.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-success.ts
index d6416e312d..e9db911f49 100644
--- a/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-success.ts
+++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-success.ts
@@ -85,7 +85,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
${this.assistConfiguration &&
this.assistConfiguration.available_wake_words.length > 1
- ? html`
+ ? html`
`
: nothing}
- `;
+ ${this.assistConfiguration &&
+ this.assistConfiguration.available_wake_words.length > 1
+ ? html``
+ : nothing}`;
}
private async _listenWakeWord() {
diff --git a/src/panels/config/application_credentials/ha-config-application-credentials.ts b/src/panels/config/application_credentials/ha-config-application-credentials.ts
index e252d2c46b..cb2fa6c5db 100644
--- a/src/panels/config/application_credentials/ha-config-application-credentials.ts
+++ b/src/panels/config/application_credentials/ha-config-application-credentials.ts
@@ -106,6 +106,7 @@ export class HaConfigApplicationCredentials extends LitElement {
},
actions: {
title: "",
+ label: localize("ui.panel.config.generic.headers.actions"),
type: "overflow-menu",
showNarrow: true,
hideable: false,
diff --git a/src/panels/config/backup/components/config/ha-backup-config-agents.ts b/src/panels/config/backup/components/config/ha-backup-config-agents.ts
index 3d6463af8c..4444e917a2 100644
--- a/src/panels/config/backup/components/config/ha-backup-config-agents.ts
+++ b/src/panels/config/backup/components/config/ha-backup-config-agents.ts
@@ -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 { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -41,13 +41,6 @@ class HaBackupConfigAgents extends LitElement {
@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() {
return this.value ?? DEFAULT_AGENTS;
}
@@ -86,19 +79,84 @@ class HaBackupConfigAgents extends LitElement {
return "";
}
- protected render() {
- const agents = this._availableAgents(this.agents, this.cloudStatus);
+ private _availableAgents = memoizeOne(
+ (agents: BackupAgent[], cloudStatus: CloudStatus) =>
+ agents.filter(
+ (agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
+ )
+ );
+
+ private _unavailableAgents = memoizeOne(
+ (
+ agents: BackupAgent[],
+ cloudStatus: CloudStatus,
+ selectedAgentIds: string[]
+ ) => {
+ const availableAgentIds = this._availableAgents(agents, cloudStatus).map(
+ (agent) => agent.agent_id
+ );
+
+ return selectedAgentIds
+ .filter((agent) => !availableAgentIds.includes(agent))
+ .map
((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`
+
+ `;
+ }
+
+ if (isNetworkMountAgent(agentId)) {
+ return html` `;
+ }
+
+ const domain = computeDomain(agentId);
+
return html`
- ${agents.length > 0
+
+ `;
+ }
+
+ 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`
- ${agents.map((agent) => {
+ ${availableAgents.map((agent) => {
const agentId = agent.agent_id;
- const domain = computeDomain(agentId);
const name = computeBackupAgentName(
this.hass.localize,
agentId,
- this.agents
+ allAgents
);
const description = this._description(agentId);
const noCloudSubscription =
@@ -108,32 +166,7 @@ class HaBackupConfigAgents extends LitElement {
return html`
- ${isLocalAgent(agentId)
- ? html`
-
-
- `
- : isNetworkMountAgent(agentId)
- ? html`
-
- `
- : html`
-
- `}
+ ${this._renderAgentIcon(agentId)}
${name}
${description
? html`${description}
`
@@ -151,14 +184,44 @@ class HaBackupConfigAgents extends LitElement {
`;
})}
+ ${unavailableAgents.length > 0 && this.showSettings
+ ? html`
+
+ ${this.hass.localize(
+ "ui.panel.config.backup.agents.unavailable_agents"
+ )}
+
+ ${unavailableAgents.map((agent) => {
+ const agentId = agent.agent_id;
+ const name = computeBackupAgentName(
+ this.hass.localize,
+ agentId,
+ allAgents
+ );
+
+ return html`
+
+ ${this._renderAgentIcon(agentId)}
+ ${name}
+
+
+ `;
+ })}
+ `
+ : nothing}
`
: html`
@@ -174,6 +237,13 @@ class HaBackupConfigAgents extends LitElement {
navigate(`/config/backup/location/${agentId}`);
}
+ private _deleteAgent(ev): void {
+ ev.stopPropagation();
+ const agentId = ev.currentTarget.id;
+ this.value = this._value.filter((agent) => agent !== agentId);
+ fireEvent(this, "value-changed", { value: this.value });
+ }
+
private _agentToggled(ev) {
ev.stopPropagation();
const value = ev.currentTarget.checked;
@@ -185,19 +255,8 @@ class HaBackupConfigAgents extends LitElement {
this.value = this._value.filter((agent) => agent !== agentId);
}
- const availableAgents = this._availableAgents(
- this.agents,
- this.cloudStatus
- );
-
// Ensure we don't have duplicates, agents exist in the list and cloud is logged in
- this.value = [...new Set(this.value)]
- .filter((id) => availableAgents.some((agent) => agent.agent_id === id))
- .filter(
- (id) =>
- id !== CLOUD_AGENT ||
- (this.cloudStatus.logged_in && this.cloudStatus.active_subscription)
- );
+ this.value = [...new Set(this.value)];
fireEvent(this, "value-changed", { value: this.value });
}
diff --git a/src/panels/config/backup/components/config/ha-backup-config-data.ts b/src/panels/config/backup/components/config/ha-backup-config-data.ts
index d1ebb6e67a..8e12093819 100644
--- a/src/panels/config/backup/components/config/ha-backup-config-data.ts
+++ b/src/panels/config/backup/components/config/ha-backup-config-data.ts
@@ -378,8 +378,9 @@ class HaBackupConfigData extends LitElement {
}
@media all and (max-width: 450px) {
ha-md-select {
- min-width: 160px;
- width: 160px;
+ min-width: 140px;
+ width: 140px;
+ --md-filled-field-content-space: 0;
}
}
`;
diff --git a/src/panels/config/backup/components/config/ha-backup-config-schedule.ts b/src/panels/config/backup/components/config/ha-backup-config-schedule.ts
index 1336473972..d57389a001 100644
--- a/src/panels/config/backup/components/config/ha-backup-config-schedule.ts
+++ b/src/panels/config/backup/components/config/ha-backup-config-schedule.ts
@@ -403,11 +403,11 @@ class HaBackupConfigSchedule extends LitElement {
backup_create: html`backup.create backup.create_automatic`,
})}
@@ -537,14 +537,22 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-list-item {
--md-item-overflow: visible;
}
- ha-md-select,
- ha-time-input {
+ ha-md-select {
min-width: 210px;
}
+ ha-time-input {
+ min-width: 194px;
+ --time-input-flex: 1;
+ }
@media all and (max-width: 450px) {
- ha-md-select,
- ha-time-input {
+ ha-md-select {
min-width: 160px;
+ width: 160px;
+ --md-filled-field-content-space: 0;
+ }
+ ha-time-input {
+ min-width: 145px;
+ width: 145px;
}
}
ha-md-textfield#value {
@@ -553,6 +561,16 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-select#type {
min-width: 100px;
}
+ @media all and (max-width: 450px) {
+ ha-md-textfield#value {
+ min-width: 60px;
+ margin: 0 -8px;
+ }
+ ha-md-select#type {
+ min-width: 120px;
+ width: 120px;
+ }
+ }
ha-expansion-panel {
--expansion-panel-summary-padding: 0 16px;
--expansion-panel-content-padding: 0 16px;
diff --git a/src/panels/config/backup/components/overview/ha-backup-overview-backups.ts b/src/panels/config/backup/components/overview/ha-backup-overview-backups.ts
index 882902b602..c140713aba 100644
--- a/src/panels/config/backup/components/overview/ha-backup-overview-backups.ts
+++ b/src/panels/config/backup/components/overview/ha-backup-overview-backups.ts
@@ -1,16 +1,19 @@
-import { mdiCalendarSync, mdiGestureTap } from "@mdi/js";
+import { mdiCalendarSync, mdiGestureTap, mdiPuzzle } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
+import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
+import type { BackupContent, BackupType } from "../../../../../data/backup";
import {
computeBackupSize,
- type BackupContent,
+ computeBackupType,
+ getBackupTypes,
} from "../../../../../data/backup";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
@@ -21,6 +24,12 @@ interface BackupStats {
size: number;
}
+const TYPE_ICONS: Record = {
+ automatic: mdiCalendarSync,
+ manual: mdiGestureTap,
+ addon_update: mdiPuzzle,
+};
+
const computeBackupStats = (backups: BackupContent[]): BackupStats =>
backups.reduce(
(stats, backup) => {
@@ -37,23 +46,22 @@ class HaBackupOverviewBackups extends LitElement {
@property({ attribute: false }) public backups: BackupContent[] = [];
- private _automaticStats = memoizeOne((backups: BackupContent[]) => {
- const automaticBackups = backups.filter(
- (backup) => backup.with_automatic_settings
- );
- return computeBackupStats(automaticBackups);
- });
-
- private _manualStats = memoizeOne((backups: BackupContent[]) => {
- const manualBackups = backups.filter(
- (backup) => !backup.with_automatic_settings
- );
- return computeBackupStats(manualBackups);
- });
+ private _stats = memoizeOne(
+ (
+ backups: BackupContent[],
+ isHassio: boolean
+ ): [BackupType, BackupStats][] =>
+ getBackupTypes(isHassio).map((type) => {
+ const backupsOfType = backups.filter(
+ (backup) => computeBackupType(backup, isHassio) === type
+ );
+ return [type, computeBackupStats(backupsOfType)] as const;
+ })
+ );
render() {
- const automaticStats = this._automaticStats(this.backups);
- const manualStats = this._manualStats(this.backups);
+ const isHassio = isComponentLoaded(this.hass, "hassio");
+ const stats = this._stats(this.backups, isHassio);
return html`
@@ -62,44 +70,32 @@ class HaBackupOverviewBackups extends LitElement {
-
-
-
- ${this.hass.localize(
- "ui.panel.config.backup.overview.backups.automatic",
- { count: automaticStats.count }
- )}
-
-
- ${this.hass.localize(
- "ui.panel.config.backup.overview.backups.total_size",
- { size: bytesToString(automaticStats.size, 1) }
- )}
-
-
-
-
-
-
- ${this.hass.localize(
- "ui.panel.config.backup.overview.backups.manual",
- { count: manualStats.count }
- )}
-
-
- ${this.hass.localize(
- "ui.panel.config.backup.overview.backups.total_size",
- { size: bytesToString(manualStats.size, 1) }
- )}
-
-
-
+ ${stats.map(
+ ([type, { count, size }]) => html`
+
+
+
+ ${this.hass.localize(
+ `ui.panel.config.backup.overview.backups.${type}`,
+ { count }
+ )}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.backup.overview.backups.total_size",
+ { size: bytesToString(size) }
+ )}
+
+
+
+ `
+ )}
diff --git a/src/panels/config/backup/dialogs/dialog-download-decrypted-backup.ts b/src/panels/config/backup/dialogs/dialog-download-decrypted-backup.ts
new file mode 100644
index 0000000000..a9ac25a9d1
--- /dev/null
+++ b/src/panels/config/backup/dialogs/dialog-download-decrypted-backup.ts
@@ -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`
+
+
+
+
+ ${this.hass.localize(
+ "ui.panel.config.backup.dialogs.download.title"
+ )}
+
+
+
+
+
+ ${this.hass.localize(
+ "ui.panel.config.backup.dialogs.download.description"
+ )}
+
+
+ ${this.hass.localize(
+ "ui.panel.config.backup.dialogs.download.download_backup_encrypted",
+ {
+ download_it_encrypted: html`
+ ${this.hass.localize(
+ "ui.panel.config.backup.dialogs.download.download_it_encrypted"
+ )}
+ `,
+ }
+ )}
+
+
+
+
+ ${this._error
+ ? html`
${this._error} `
+ : nothing}
+
+
+
+ ${this.hass.localize("ui.dialogs.generic.cancel")}
+
+
+
+ ${this.hass.localize(
+ "ui.panel.config.backup.dialogs.download.download"
+ )}
+
+
+
+ `;
+ }
+
+ 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;
+ }
+}
diff --git a/src/panels/config/backup/dialogs/show-dialog-download-decrypted-backup.ts b/src/panels/config/backup/dialogs/show-dialog-download-decrypted-backup.ts
new file mode 100644
index 0000000000..9f6984171d
--- /dev/null
+++ b/src/panels/config/backup/dialogs/show-dialog-download-decrypted-backup.ts
@@ -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,
+ });
+};
diff --git a/src/panels/config/backup/ha-config-backup-backups.ts b/src/panels/config/backup/ha-config-backup-backups.ts
index 315403bbed..6caa2190af 100644
--- a/src/panels/config/backup/ha-config-backup-backups.ts
+++ b/src/panels/config/backup/ha-config-backup-backups.ts
@@ -11,6 +11,7 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
+import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
@@ -42,9 +43,11 @@ import {
compareAgents,
computeBackupAgentName,
computeBackupSize,
+ computeBackupType,
deleteBackup,
generateBackup,
generateBackupWithAutomaticSettings,
+ getBackupTypes,
isLocalAgent,
isNetworkMountAgent,
} from "../../../data/backup";
@@ -74,10 +77,6 @@ interface BackupRow extends DataTableRowData, BackupContent {
agent_ids: string[];
}
-type BackupType = "automatic" | "manual";
-
-const TYPE_ORDER: BackupType[] = ["automatic", "manual"];
-
@customElement("ha-config-backup-backups")
class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -141,7 +140,10 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
};
private _columns = memoizeOne(
- (localize: LocalizeFunc): DataTableColumnContainer
=> ({
+ (
+ localize: LocalizeFunc,
+ maxDisplayedAgents: number
+ ): DataTableColumnContainer => ({
name: {
title: localize("ui.panel.config.backup.name"),
main: true,
@@ -172,54 +174,75 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
locations: {
title: localize("ui.panel.config.backup.locations"),
showNarrow: true,
- minWidth: "60px",
- template: (backup) => html`
-
- ${(backup.agent_ids || []).map((agentId) => {
- const name = computeBackupAgentName(
- this.hass.localize,
- agentId,
- this.agents
- );
- if (isLocalAgent(agentId)) {
+ // 24 icon size, 4 gap, 16 left and right padding
+ minWidth: `${maxDisplayedAgents * 24 + (maxDisplayedAgents - 1) * 4 + 32}px`,
+ template: (backup) => {
+ const agentIds = backup.agent_ids;
+ const displayedAgentIds =
+ agentIds.length > maxDisplayedAgents
+ ? [...agentIds].splice(0, maxDisplayedAgents - 1)
+ : agentIds;
+ const agentsMore = Math.max(
+ agentIds.length - displayedAgentIds.length,
+ 0
+ );
+ return html`
+
+ ${displayedAgentIds.map((agentId) => {
+ const name = computeBackupAgentName(
+ this.hass.localize,
+ agentId,
+ this.agents
+ );
+ if (isLocalAgent(agentId)) {
+ return html`
+
+ `;
+ }
+ if (isNetworkMountAgent(agentId)) {
+ return html`
+
+ `;
+ }
+ const domain = computeDomain(agentId);
return html`
-
+ />
`;
- }
- if (isNetworkMountAgent(agentId)) {
- return html`
-
- `;
- }
- const domain = computeDomain(agentId);
- return html`
-
- `;
- })}
-
- `,
+ })}
+ ${agentsMore
+ ? html`
+
+ +${agentsMore}
+
+ `
+ : nothing}
+
+ `;
+ },
},
actions: {
title: "",
@@ -253,9 +276,13 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
);
private _groupOrder = memoizeOne(
- (activeGrouping: string | undefined, localize: LocalizeFunc) =>
+ (
+ activeGrouping: string | undefined,
+ localize: LocalizeFunc,
+ isHassio: boolean
+ ) =>
activeGrouping === "formatted_type"
- ? TYPE_ORDER.map((type) =>
+ ? getBackupTypes(isHassio).map((type) =>
localize(`ui.panel.config.backup.type.${type}`)
)
: undefined
@@ -279,33 +306,48 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
(
backups: BackupContent[],
filters: DataTableFiltersValues,
- localize: LocalizeFunc
+ localize: LocalizeFunc,
+ isHassio: boolean
): BackupRow[] => {
const typeFilter = filters["ha-filter-states"] as string[] | undefined;
let filteredBackups = backups;
if (typeFilter?.length) {
- filteredBackups = filteredBackups.filter(
- (backup) =>
- (backup.with_automatic_settings &&
- typeFilter.includes("automatic")) ||
- (!backup.with_automatic_settings && typeFilter.includes("manual"))
- );
+ filteredBackups = filteredBackups.filter((backup) => {
+ const type = computeBackupType(backup, isHassio);
+ return typeFilter.includes(type);
+ });
}
return filteredBackups.map((backup) => {
- const type = backup.with_automatic_settings ? "automatic" : "manual";
+ const type = computeBackupType(backup, isHassio);
+ const agentIds = Object.keys(backup.agents);
return {
...backup,
size: computeBackupSize(backup),
- agent_ids: Object.keys(backup.agents).sort(compareAgents),
+ agent_ids: agentIds.sort(compareAgents),
formatted_type: localize(`ui.panel.config.backup.type.${type}`),
};
});
}
);
+ private _maxAgents = memoizeOne((data: BackupRow[]): number =>
+ Math.max(...data.map((row) => row.agent_ids.length))
+ );
+
protected render(): TemplateResult {
const backupInProgress =
"state" in this.manager && this.manager.state === "in_progress";
+ const isHassio = isComponentLoaded(this.hass, "hassio");
+ const data = this._data(
+ this.backups,
+ this._filters,
+ this.hass.localize,
+ isHassio
+ );
+ const maxDisplayedAgents = Math.min(
+ this._maxAgents(data),
+ this.narrow ? 3 : 5
+ );
return html`
- TYPE_ORDER.map((type) => ({
+ private _states = memoizeOne((localize: LocalizeFunc, isHassio: boolean) =>
+ getBackupTypes(isHassio).map((type) => ({
value: type,
label: localize(`ui.panel.config.backup.type.${type}`),
}))
@@ -496,12 +539,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
private async _downloadBackup(backup: BackupContent): Promise {
- downloadBackup(
- this.hass,
- this,
- backup,
- this.config?.create_backup.password
- );
+ downloadBackup(this.hass, this, backup, this.config);
}
private async _deleteBackup(backup: BackupContent): Promise {
diff --git a/src/panels/config/backup/ha-config-backup-details.ts b/src/panels/config/backup/ha-config-backup-details.ts
index 6a9d8706c3..c3bbd93552 100644
--- a/src/panels/config/backup/ha-config-backup-details.ts
+++ b/src/panels/config/backup/ha-config-backup-details.ts
@@ -31,6 +31,7 @@ import {
compareAgents,
computeBackupAgentName,
computeBackupSize,
+ computeBackupType,
deleteBackup,
fetchBackupDetails,
isLocalAgent,
@@ -46,6 +47,7 @@ import { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup";
import { fireEvent } from "../../../common/dom/fire_event";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { downloadBackup } from "./helper/download_backup";
+import { isComponentLoaded } from "../../../common/config/is_component_loaded";
interface Agent extends BackupContentAgent {
id: string;
@@ -110,6 +112,8 @@ class HaConfigBackupDetails extends LitElement {
return nothing;
}
+ const isHassio = isComponentLoaded(this.hass, "hassio");
+
return html`
+
+
+ ${this.hass.localize(
+ "ui.panel.config.backup.backup_type"
+ )}
+
+
+ ${this.hass.localize(
+ `ui.panel.config.backup.type.${computeBackupType(this._backup, isHassio)}`
+ )}
+
+
${this.hass.localize(
@@ -401,13 +417,7 @@ class HaConfigBackupDetails extends LitElement {
}
private async _downloadBackup(agentId?: string): Promise {
- await downloadBackup(
- this.hass,
- this,
- this._backup!,
- this.config?.create_backup.password,
- agentId
- );
+ await downloadBackup(this.hass, this, this._backup!, this.config, agentId);
}
private async _deleteBackup(): Promise {
diff --git a/src/panels/config/backup/ha-config-backup-overview.ts b/src/panels/config/backup/ha-config-backup-overview.ts
index e71212917c..8d23b61b66 100644
--- a/src/panels/config/backup/ha-config-backup-overview.ts
+++ b/src/panels/config/backup/ha-config-backup-overview.ts
@@ -221,8 +221,7 @@ class HaConfigBackupOverview extends LitElement {
gap: 24px;
display: flex;
flex-direction: column;
- margin-bottom: 24px;
- margin-bottom: 72px;
+ margin-bottom: calc(env(safe-area-inset-bottom) + 72px);
}
.card-actions {
display: flex;
diff --git a/src/panels/config/backup/ha-config-backup-settings.ts b/src/panels/config/backup/ha-config-backup-settings.ts
index 6b800b3574..d7bb4266da 100644
--- a/src/panels/config/backup/ha-config-backup-settings.ts
+++ b/src/panels/config/backup/ha-config-backup-settings.ts
@@ -50,9 +50,11 @@ class HaConfigBackupSettings extends LitElement {
}
}
- protected firstUpdated(_changedProperties: PropertyValues): void {
- super.firstUpdated(_changedProperties);
+ public connectedCallback(): void {
+ super.connectedCallback();
this._scrollToSection();
+ // Update config the page is displayed (e.g. when coming back from a location detail page)
+ this._config = this.config;
}
private async _scrollToSection() {
diff --git a/src/panels/config/backup/ha-config-backup.ts b/src/panels/config/backup/ha-config-backup.ts
index 245927dffe..b488058b58 100644
--- a/src/panels/config/backup/ha-config-backup.ts
+++ b/src/panels/config/backup/ha-config-backup.ts
@@ -119,6 +119,7 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
settings: {
tag: "ha-config-backup-settings",
load: () => import("./ha-config-backup-settings"),
+ cache: true,
},
location: {
tag: "ha-config-backup-location",
diff --git a/src/panels/config/backup/helper/download_backup.ts b/src/panels/config/backup/helper/download_backup.ts
index e6b2f8d2c7..3c08cc1df7 100644
--- a/src/panels/config/backup/helper/download_backup.ts
+++ b/src/panels/config/backup/helper/download_backup.ts
@@ -1,20 +1,17 @@
import type { LitElement } from "lit";
+import { getSignedPath } from "../../../../data/auth";
+import type { BackupConfig, BackupContent } from "../../../../data/backup";
import {
canDecryptBackupOnDownload,
getBackupDownloadUrl,
getPreferredAgentForDownload,
- type BackupContent,
} from "../../../../data/backup";
import type { HomeAssistant } from "../../../../types";
-import {
- showAlertDialog,
- showConfirmationDialog,
- showPromptDialog,
-} from "../../../lovelace/custom-card-helpers";
-import { getSignedPath } from "../../../../data/auth";
import { fileDownload } from "../../../../util/file_download";
+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,
backupId: string,
preferedAgent: string,
@@ -27,120 +24,80 @@ const triggerDownload = async (
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 => {
- const encryptionKey = await showPromptDialog(element, {
- title: hass.localize(
- "ui.panel.config.backup.dialogs.show_encryption_key.title"
- ),
- text: hass.localize(
- "ui.panel.config.backup.dialogs.download.incorrect_current_encryption_key"
- ),
- inputLabel: hass.localize(
- "ui.panel.config.backup.dialogs.show_encryption_key.title"
- ),
- inputType: "password",
- confirmText: hass.localize("ui.common.download"),
- });
- if (encryptionKey === null) {
- return;
- }
- downloadBackup(hass, element, backup, encryptionKey, agentId, true);
-};
-
export const downloadBackup = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
- encryptionKey?: string | null,
- agentId?: string,
- userProvided = false
+ backupConfig?: BackupConfig,
+ agentId?: string
): Promise => {
const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
const isProtected = backup.agents[preferedAgent]?.protected;
- if (isProtected) {
- if (encryptionKey) {
- try {
- await canDecryptBackupOnDownload(
- hass,
- backup.backup_id,
- preferedAgent,
- encryptionKey
- );
- } catch (err: any) {
- if (err?.code === "password_incorrect") {
- if (userProvided) {
- downloadEncryptedBackup(hass, element, backup, agentId);
- } else {
- requestEncryptionKey(hass, element, backup, agentId);
- }
- return;
- }
- if (err?.code === "decrypt_not_supported") {
- showAlertDialog(element, {
- title: hass.localize(
- "ui.panel.config.backup.dialogs.download.decryption_unsupported_title"
- ),
- text: hass.localize(
- "ui.panel.config.backup.dialogs.download.decryption_unsupported"
- ),
- confirm() {
- triggerDownload(hass, backup.backup_id, preferedAgent);
- },
- });
- encryptionKey = undefined;
- return;
- }
-
- showAlertDialog(element, {
- title: hass.localize(
- "ui.panel.config.backup.dialogs.download.error_check_title",
- {
- error: err.message,
- }
- ),
- text: hass.localize(
- "ui.panel.config.backup.dialogs.download.error_check_description",
- {
- error: err.message,
- }
- ),
- });
- return;
- }
- } else {
- requestEncryptionKey(hass, element, backup, agentId);
- return;
- }
+ if (!isProtected) {
+ downloadBackupFile(hass, backup.backup_id, preferedAgent);
+ 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,
+ }
+ ),
+ });
+ }
};
diff --git a/src/panels/config/cloud/account/cloud-account.ts b/src/panels/config/cloud/account/cloud-account.ts
index 5ccbe1ef60..82730ce21c 100644
--- a/src/panels/config/cloud/account/cloud-account.ts
+++ b/src/panels/config/cloud/account/cloud-account.ts
@@ -1,15 +1,15 @@
import "@material/mwc-button";
+import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
-import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js";
import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { fireEvent } from "../../../../common/dom/fire_event";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
-import "../../../../components/ha-card";
-import "../../../../components/ha-tip";
-import "../../../../components/ha-list-item";
import "../../../../components/ha-button-menu";
+import "../../../../components/ha-card";
+import "../../../../components/ha-list-item";
+import "../../../../components/ha-tip";
import type {
CloudStatusLoggedIn,
SubscriptionInfo,
@@ -32,6 +32,7 @@ import "./cloud-ice-servers-pref";
import "./cloud-remote-pref";
import "./cloud-tts-pref";
import "./cloud-webhooks";
+import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
@customElement("cloud-account")
export class CloudAccount extends SubscribeMixin(LitElement) {
@@ -52,7 +53,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
header="Home Assistant Cloud"
>
-
+
+
+ ${this.hass.localize(
+ "ui.panel.config.cloud.account.download_support_package"
+ )}
+
+
@@ -286,6 +293,16 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
fireEvent(this, "ha-refresh-cloud-status");
}
+ private _handleMenuAction(ev) {
+ switch (ev.detail.index) {
+ case 0:
+ this._deleteCloudData();
+ break;
+ case 1:
+ this._downloadSupportPackage();
+ }
+ }
+
private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
@@ -316,6 +333,10 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
}
}
+ private async _downloadSupportPackage() {
+ showSupportPackageDialog(this);
+ }
+
static get styles() {
return [
haStyle,
diff --git a/src/panels/config/cloud/account/dialog-cloud-support-package.ts b/src/panels/config/cloud/account/dialog-cloud-support-package.ts
new file mode 100644
index 0000000000..920db783a4
--- /dev/null
+++ b/src/panels/config/cloud/account/dialog-cloud-support-package.ts
@@ -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`
+
+
+
+ Download support package
+
+
+
+ ${this._supportPackage
+ ? html`
`
+ : html`
+
+
+ Generating preview...
+
+ `}
+
+
+
+ `;
+ }
+
+ 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;
+ }
+}
diff --git a/src/panels/config/cloud/account/show-dialog-cloud-support-package.ts b/src/panels/config/cloud/account/show-dialog-cloud-support-package.ts
new file mode 100644
index 0000000000..697b16d0e9
--- /dev/null
+++ b/src/panels/config/cloud/account/show-dialog-cloud-support-package.ts
@@ -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: {},
+ });
+};
diff --git a/src/panels/config/cloud/login/cloud-login.ts b/src/panels/config/cloud/login/cloud-login.ts
index 1a05db180f..5fe14e24ba 100644
--- a/src/panels/config/cloud/login/cloud-login.ts
+++ b/src/panels/config/cloud/login/cloud-login.ts
@@ -1,6 +1,6 @@
import "@material/mwc-button";
import "@material/mwc-list/mwc-list";
-import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js";
+import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -27,6 +27,7 @@ import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
+import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
@customElement("cloud-login")
export class CloudLogin extends LitElement {
@@ -57,7 +58,7 @@ export class CloudLogin extends LitElement {
.narrow=${this.narrow}
header="Home Assistant Cloud"
>
-
+
+
+ ${this.hass.localize(
+ "ui.panel.config.cloud.account.download_support_package"
+ )}
+
+
@@ -348,6 +355,16 @@ export class CloudLogin extends LitElement {
fireEvent(this, "flash-message-changed", { value: "" });
}
+ private _handleMenuAction(ev) {
+ switch (ev.detail.index) {
+ case 0:
+ this._deleteCloudData();
+ break;
+ case 1:
+ this._downloadSupportPackage();
+ }
+ }
+
private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
@@ -377,6 +394,10 @@ export class CloudLogin extends LitElement {
}
}
+ private async _downloadSupportPackage() {
+ showSupportPackageDialog(this);
+ }
+
static get styles() {
return [
haStyle,
diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts
index ad7e23738f..84d72920e0 100644
--- a/src/panels/config/devices/ha-config-device-page.ts
+++ b/src/panels/config/devices/ha-config-device-page.ts
@@ -1073,7 +1073,14 @@ export class HaConfigDevicePage extends LitElement {
(ent) => computeDomain(ent.entity_id) === "assist_satellite"
);
+ const domains = this._integrations(
+ device,
+ this.entries,
+ this.manifests
+ ).map((int) => int.domain);
+
if (
+ !domains.includes("voip") &&
assistSatellite &&
assistSatelliteSupportsSetupFlow(
this.hass.states[assistSatellite.entity_id]
@@ -1088,12 +1095,6 @@ export class HaConfigDevicePage extends LitElement {
});
}
- const domains = this._integrations(
- device,
- this.entries,
- this.manifests
- ).map((int) => int.domain);
-
if (domains.includes("mqtt")) {
const mqtt = await import(
"./device-detail/integration-elements/mqtt/device-actions"
diff --git a/src/panels/config/hardware/ha-config-hardware.ts b/src/panels/config/hardware/ha-config-hardware.ts
index 004bcb0cd1..12c14def9a 100644
--- a/src/panels/config/hardware/ha-config-hardware.ts
+++ b/src/panels/config/hardware/ha-config-hardware.ts
@@ -39,7 +39,6 @@ import { hardwareBrandsUrl } from "../../../util/brands-url";
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { ECOption } from "../../../resources/echarts";
-import { getTimeAxisLabelConfig } from "../../../components/chart/axis-label";
const DATASAMPLES = 60;
@@ -153,13 +152,6 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
this._chartOptions = {
xAxis: {
type: "time",
- axisLabel: getTimeAxisLabelConfig(this.hass.locale, this.hass.config),
- splitLine: {
- show: true,
- },
- axisLine: {
- show: false,
- },
},
yAxis: {
type: "value",
diff --git a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-advertisement-monitor.ts b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-advertisement-monitor.ts
index e3d9122a4b..361eaced72 100644
--- a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-advertisement-monitor.ts
+++ b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-advertisement-monitor.ts
@@ -1,8 +1,9 @@
+import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
-import type { UnsubscribeFunc } from "home-assistant-js-websocket";
+import { storage } from "../../../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type {
@@ -11,9 +12,6 @@ import type {
} from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-fab";
import "../../../../../components/ha-icon-button";
-import "../../../../../layouts/hass-tabs-subpage-data-table";
-import { haStyle } from "../../../../../resources/styles";
-import type { HomeAssistant, Route } from "../../../../../types";
import type {
BluetoothDeviceData,
BluetoothScannersDetails,
@@ -22,6 +20,10 @@ import {
subscribeBluetoothAdvertisements,
subscribeBluetoothScannersDetails,
} 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";
@customElement("bluetooth-advertisement-monitor")
@@ -38,6 +40,22 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
@state() private _scanners: BluetoothScannersDetails = {};
+ @state() private _sourceDevices: Record = {};
+
+ @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_scanners?: UnsubscribeFunc;
@@ -57,6 +75,19 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
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,
moveable: false,
direction: "asc",
- flex: 2,
+ flex: 1,
},
name: {
title: localize("ui.panel.config.bluetooth.name"),
filterable: true,
sortable: true,
},
+ device: {
+ title: localize("ui.panel.config.bluetooth.device"),
+ filterable: true,
+ sortable: true,
+ template: (data) => html`${data.device || "-"}`,
+ },
source: {
title: localize("ui.panel.config.bluetooth.source"),
filterable: true,
sortable: true,
+ groupable: true,
+ },
+ source_address: {
+ title: localize("ui.panel.config.bluetooth.source_address"),
+ filterable: true,
+ sortable: true,
+ defaultHidden: true,
},
rssi: {
title: localize("ui.panel.config.bluetooth.rssi"),
type: "numeric",
+ maxWidth: "60px",
sortable: true,
},
};
@@ -108,11 +153,22 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
);
private _dataWithNamedSourceAndIds = memoizeOne((data) =>
- data.map((row) => ({
- ...row,
- id: row.address,
- source: this._scanners[row.source]?.name || row.source,
- }))
+ data.map((row) => {
+ const device = this._sourceDevices[row.address];
+ const scannerDevice = this._sourceDevices[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 {
@@ -124,11 +180,23 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
.columns=${this._columns(this.hass.localize)}
.data=${this._dataWithNamedSourceAndIds(this._data)}
@row-click=${this._handleRowClicked}
+ .initialGroupColumn=${this._activeGrouping}
+ .initialCollapsedGroups=${this._activeCollapsed}
+ @grouping-changed=${this._handleGroupingChanged}
+ @collapsed-changed=${this._handleCollapseChanged}
clickable
>
`;
}
+ private _handleGroupingChanged(ev: CustomEvent) {
+ this._activeGrouping = ev.detail.value;
+ }
+
+ private _handleCollapseChanged(ev: CustomEvent) {
+ this._activeCollapsed = ev.detail.value;
+ }
+
private _handleRowClicked(ev: HASSDomEvent) {
const entry = this._data.find((ent) => ent.address === ev.detail.id);
showBluetoothDeviceInfoDialog(this, {
diff --git a/src/panels/config/integrations/integration-panels/bluetooth/dialog-bluetooth-device-info.ts b/src/panels/config/integrations/integration-panels/bluetooth/dialog-bluetooth-device-info.ts
index 2aeb28cb77..635da34b03 100644
--- a/src/panels/config/integrations/integration-panels/bluetooth/dialog-bluetooth-device-info.ts
+++ b/src/panels/config/integrations/integration-panels/bluetooth/dialog-bluetooth-device-info.ts
@@ -53,8 +53,6 @@ class DialogBluetoothDeviceInfo extends LitElement implements HassDialog {
return html`
3
- ? formatShortDateTime(
+ ? formatShortDateTimeWithConditionalYear(
date,
this.hass.locale,
this.hass.config
diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts
index 977134745d..c535c4ac8b 100644
--- a/src/panels/history/ha-panel-history.ts
+++ b/src/panels/history/ha-panel-history.ts
@@ -173,7 +173,7 @@ class HaPanelHistory extends LitElement {
.endDate=${this._endDate}
extended-presets
time-picker
- @change=${this._dateRangeChanged}
+ @value-changed=${this._dateRangeChanged}
>
@@ -233,8 +233,8 @@ export class HaPanelLogbook extends LitElement {
}
private _dateRangeChanged(ev) {
- const startDate = ev.detail.startDate;
- const endDate = ev.detail.endDate;
+ const startDate = ev.detail.value.startDate;
+ const endDate = ev.detail.value.endDate;
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
endDate.setDate(endDate.getDate() + 1);
endDate.setMilliseconds(endDate.getMilliseconds() - 1);
diff --git a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts
index 3619c731d3..2e576c7c9e 100644
--- a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts
+++ b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts
@@ -1,5 +1,16 @@
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 {
BarSeriesOption,
CallbackDataParams,
@@ -7,10 +18,12 @@ import type {
} from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../../../../data/translation";
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 type { ECOption } from "../../../../../resources/echarts";
-import { getTimeAxisLabelConfig } from "../../../../../components/chart/axis-label";
export function getSuggestedMax(dayDifference: number, end: Date): number {
let suggestedMax = new Date(end);
@@ -52,23 +65,9 @@ export function getCommonOptions(
const options: ECOption = {
xAxis: {
- id: "xAxisMain",
type: "time",
- min: start.getTime(),
+ min: start,
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: {
type: "value",
@@ -103,7 +102,6 @@ export function getCommonOptions(
}
});
return [mainItems, compareItems]
- .filter((items) => items.length > 0)
.map((items) =>
formatTooltip(
items,
@@ -115,6 +113,7 @@ export function getCommonOptions(
formatTotal
)
)
+ .filter(Boolean)
.join(" ");
}
return formatTooltip(
@@ -141,14 +140,16 @@ function formatTooltip(
unit?: string,
formatTotal?: (total: number) => string
) {
- if (!params[0].value) {
+ if (!params[0]?.value) {
return "";
}
// when comparing the first value is offset to match the main period
// and the real date is in the third value
const date = new Date(params[0].value?.[2] ?? params[0].value?.[0]);
let period: string;
- if (dayDifference > 0) {
+ if (dayDifference > 89) {
+ period = `${formatDateMonthYear(date, locale, config)}`;
+ } else if (dayDifference > 0) {
period = `${formatDateVeryShort(date, locale, config)}`;
} else {
period = `${
@@ -198,7 +199,9 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
const buckets = Array.from(
new Set(
datasets
- .map((dataset) => dataset.data!.map((datapoint) => datapoint![0]))
+ .map((dataset) =>
+ dataset.data!.map((datapoint) => Number(datapoint![0]))
+ )
.flat()
)
).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);
+}
diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts
index 943a3b6457..a1501155b9 100644
--- a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts
+++ b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts
@@ -33,6 +33,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
+ getCompareTransform,
} from "./common/energy-chart-options";
import { storage } from "../../../../common/decorators/storage";
import type { ECOption } from "../../../../resources/echarts";
@@ -314,29 +315,34 @@ export class HuiEnergyDevicesDetailGraphCard
processedData.forEach((device) => {
device.data.forEach((datapoint) => {
- totalDeviceConsumption[datapoint[0]] =
- (totalDeviceConsumption[datapoint[0]] || 0) + datapoint[1];
+ totalDeviceConsumption[datapoint[compare ? 2 : 0]] =
+ (totalDeviceConsumption[datapoint[compare ? 2 : 0]] || 0) +
+ datapoint[1];
});
});
- const compareOffset = compare
- ? this._start.getTime() - this._compareStart!.getTime()
- : 0;
+ const compareTransform = getCompareTransform(
+ this._start,
+ this._compareStart!
+ );
const untrackedConsumption: BarSeriesOption["data"] = [];
Object.keys(consumptionData.total).forEach((time) => {
+ const ts = Number(time);
const value =
consumptionData.total[time] - (totalDeviceConsumption[time] || 0);
- const dataPoint = [Number(time), value];
+ const dataPoint: number[] = [ts, value];
if (compare) {
dataPoint[2] = dataPoint[0];
- dataPoint[0] += compareOffset;
+ dataPoint[0] = compareTransform(new Date(ts)).getTime();
}
untrackedConsumption.push(dataPoint);
});
+ // random id to always add untracked at the end
+ const order = Date.now();
const dataset: BarSeriesOption = {
type: "bar",
cursor: "default",
- id: compare ? "compare-untracked" : "untracked",
+ id: compare ? `compare-untracked-${order}` : `untracked-${order}`,
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
@@ -372,9 +378,10 @@ export class HuiEnergyDevicesDetailGraphCard
compare = false
) {
const data: BarSeriesOption[] = [];
- const compareOffset = compare
- ? this._start.getTime() - this._compareStart!.getTime()
- : 0;
+ const compareTransform = getCompareTransform(
+ this._start,
+ this._compareStart!
+ );
devices.forEach((source, idx) => {
const order = sorted_devices.indexOf(source.stat_consumption);
@@ -409,7 +416,7 @@ export class HuiEnergyDevicesDetailGraphCard
const dataPoint = [point.start, point.change];
if (compare) {
dataPoint[2] = dataPoint[0];
- dataPoint[0] += compareOffset;
+ dataPoint[0] = compareTransform(new Date(point.start)).getTime();
}
consumptionData.push(dataPoint);
prevStart = point.start;
@@ -419,9 +426,10 @@ export class HuiEnergyDevicesDetailGraphCard
data.push({
type: "bar",
cursor: "default",
+ // add order to id, otherwise echarts refuses to reorder them
id: compare
- ? `compare-${source.stat_consumption}`
- : source.stat_consumption,
+ ? `compare-${source.stat_consumption}-${order}`
+ : `${source.stat_consumption}-${order}`,
name:
source.name ||
getStatisticLabel(
@@ -438,7 +446,9 @@ export class HuiEnergyDevicesDetailGraphCard
stack: compare ? "devicesCompare" : "devices",
});
});
- return data;
+ return sorted_devices.map(
+ (device) => data.find((d) => (d.id as string).includes(device))!
+ );
}
static styles = css`
diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts
index e12f747c90..3c99584415 100644
--- a/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts
+++ b/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts
@@ -88,7 +88,7 @@ export class HuiEnergyDevicesGraphCard
@@ -110,18 +110,17 @@ export class HuiEnergyDevicesGraphCard
}
private _createOptions = memoizeOne(
- (darkMode: boolean): ECOption => ({
+ (data: BarSeriesOption[]): ECOption => ({
xAxis: {
type: "value",
name: "kWh",
- splitLine: {
- lineStyle: darkMode ? { opacity: 0.15 } : {},
- },
},
yAxis: {
type: "category",
inverse: true,
triggerEvent: true,
+ // take order from data
+ data: data[0]?.data?.map((d: any) => d.value[1]),
axisLabel: {
formatter: this._getDeviceName.bind(this),
overflow: "truncate",
diff --git a/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts
index 0c19285d70..6612a58c8f 100644
--- a/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts
+++ b/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts
@@ -29,6 +29,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
+ getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
@@ -213,9 +214,10 @@ export class HuiEnergyGasGraphCard
compare = false
) {
const data: BarSeriesOption[] = [];
- const compareOffset = compare
- ? this._start.getTime() - this._compareStart!.getTime()
- : 0;
+ const compareTransform = getCompareTransform(
+ this._start,
+ this._compareStart!
+ );
gasSources.forEach((source, idx) => {
let prevStart: number | null = null;
@@ -236,10 +238,13 @@ export class HuiEnergyGasGraphCard
if (prevStart === point.start) {
continue;
}
- const dataPoint = [point.start, point.change];
+ const dataPoint: (Date | string | number)[] = [
+ point.start,
+ point.change,
+ ];
if (compare) {
dataPoint[2] = dataPoint[0];
- dataPoint[0] += compareOffset;
+ dataPoint[0] = compareTransform(new Date(point.start));
}
gasConsumptionData.push(dataPoint);
prevStart = point.start;
diff --git a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts
index e679022a1b..e1ffa869f0 100644
--- a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts
+++ b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts
@@ -30,6 +30,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
+ getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
@@ -231,9 +232,10 @@ export class HuiEnergySolarGraphCard
compare = false
) {
const data: BarSeriesOption[] = [];
- const compareOffset = compare
- ? this._start.getTime() - this._compareStart!.getTime()
- : 0;
+ const compareTransform = getCompareTransform(
+ this._start,
+ this._compareStart!
+ );
solarSources.forEach((source, idx) => {
let prevStart: number | null = null;
@@ -255,10 +257,13 @@ export class HuiEnergySolarGraphCard
if (prevStart === point.start) {
continue;
}
- const dataPoint = [point.start, point.change];
+ const dataPoint: (Date | string | number)[] = [
+ point.start,
+ point.change,
+ ];
if (compare) {
dataPoint[2] = dataPoint[0];
- dataPoint[0] += compareOffset;
+ dataPoint[0] = compareTransform(new Date(point.start));
}
solarProductionData.push(dataPoint);
prevStart = point.start;
diff --git a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts
index 74fc5dc83f..9836c7f061 100644
--- a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts
+++ b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts
@@ -27,6 +27,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
+ getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
@@ -476,9 +477,10 @@ export class HuiEnergyUsageGraphCard
(a, b) => Number(a) - Number(b)
);
- const compareOffset = compare
- ? this._start.getTime() - this._compareStart!.getTime()
- : 0;
+ const compareTransform = getCompareTransform(
+ this._start,
+ this._compareStart!
+ );
Object.entries(combinedData).forEach(([type, sources]) => {
Object.entries(sources).forEach(([statId, source]) => {
@@ -494,7 +496,7 @@ export class HuiEnergyUsageGraphCard
];
if (compare) {
dataPoint[2] = dataPoint[0];
- dataPoint[0] += compareOffset;
+ dataPoint[0] = compareTransform(dataPoint[0]);
}
points.push(dataPoint);
}
diff --git a/src/panels/lovelace/cards/energy/hui-energy-water-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-water-graph-card.ts
index 919bd06ed1..adfc3d217a 100644
--- a/src/panels/lovelace/cards/energy/hui-energy-water-graph-card.ts
+++ b/src/panels/lovelace/cards/energy/hui-energy-water-graph-card.ts
@@ -28,6 +28,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
+ getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
import { formatNumber } from "../../../../common/number/format_number";
@@ -211,9 +212,10 @@ export class HuiEnergyWaterGraphCard
compare = false
) {
const data: BarSeriesOption[] = [];
- const compareOffset = compare
- ? this._start.getTime() - this._compareStart!.getTime()
- : 0;
+ const compareTransform = getCompareTransform(
+ this._start,
+ this._compareStart!
+ );
waterSources.forEach((source, idx) => {
let prevStart: number | null = null;
@@ -234,10 +236,13 @@ export class HuiEnergyWaterGraphCard
if (prevStart === point.start) {
continue;
}
- const dataPoint = [point.start, point.change];
+ const dataPoint: (Date | string | number)[] = [
+ point.start,
+ point.change,
+ ];
if (compare) {
dataPoint[2] = dataPoint[0];
- dataPoint[0] += compareOffset;
+ dataPoint[0] = compareTransform(new Date(point.start));
}
waterConsumptionData.push(dataPoint);
prevStart = point.start;
diff --git a/src/panels/lovelace/cards/hui-history-graph-card.ts b/src/panels/lovelace/cards/hui-history-graph-card.ts
index b77360cef8..0907d60254 100644
--- a/src/panels/lovelace/cards/hui-history-graph-card.ts
+++ b/src/panels/lovelace/cards/hui-history-graph-card.ts
@@ -65,7 +65,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
return {
columns: 12,
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 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`
@@ -259,6 +260,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
${this._error
@@ -283,9 +285,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.minYAxis=${this._config.min_y_axis}
.maxYAxis=${this._config.max_y_axis}
.fitYData=${this._config.fit_y_data || false}
- .height=${this._config.grid_options?.rows
- ? "100%"
- : undefined}
+ .height=${hasFixedHeight ? "100%" : undefined}
.narrow=${narrow}
>
`}
@@ -303,6 +303,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.card-header {
justify-content: space-between;
display: flex;
+ padding-bottom: 0;
}
.card-header ha-icon-next {
--mdc-icon-button-size: 24px;
@@ -310,7 +311,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
color: var(--primary-text-color);
}
.content {
- padding: 16px;
+ padding: 0 16px 8px 16px;
flex: 1;
}
.has-header {
@@ -318,6 +319,10 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
}
state-history-charts {
height: 100%;
+ --timeline-top-margin: 16px;
+ }
+ .has-rows {
+ --chart-max-height: 100%;
}
`;
}
diff --git a/src/panels/lovelace/cards/hui-statistics-graph-card.ts b/src/panels/lovelace/cards/hui-statistics-graph-card.ts
index 1ecde26862..3754725c9d 100644
--- a/src/panels/lovelace/cards/hui-statistics-graph-card.ts
+++ b/src/panels/lovelace/cards/hui-statistics-graph-card.ts
@@ -6,7 +6,10 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../components/ha-card";
import { getEnergyDataCollection } from "../../../data/energy";
-import { getSuggestedPeriod } from "./energy/common/energy-chart-options";
+import {
+ getSuggestedMax,
+ getSuggestedPeriod,
+} from "./energy/common/energy-chart-options";
import type {
Statistics,
StatisticsMetaData,
@@ -255,8 +258,13 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
return nothing;
}
+ const hasFixedHeight = typeof this._config.grid_options?.rows === "number";
+
return html`
-
+
+ ${this._config.title
+ ? html``
+ : nothing}
@@ -358,8 +375,12 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
flex-direction: column;
height: 100%;
}
+ .card-header {
+ padding-bottom: 0;
+ }
.content {
padding: 16px;
+ padding-top: 0;
flex: 1;
}
.has-header {
diff --git a/src/panels/lovelace/components/hui-energy-period-selector.ts b/src/panels/lovelace/components/hui-energy-period-selector.ts
index a32164ef5e..86add3acdd 100644
--- a/src/panels/lovelace/components/hui-energy-period-selector.ts
+++ b/src/panels/lovelace/components/hui-energy-period-selector.ts
@@ -246,7 +246,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
.startDate=${this._startDate}
.endDate=${this._endDate || new Date()}
.ranges=${this._ranges}
- @change=${this._dateRangeChanged}
+ @value-changed=${this._dateRangeChanged}
minimal
>
@@ -346,7 +346,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
private _dateRangeChanged(ev) {
const weekStartsOn = firstWeekdayIndex(this.hass.locale);
this._startDate = calcDate(
- ev.detail.startDate,
+ ev.detail.value.startDate,
startOfDay,
this.hass.locale,
this.hass.config,
@@ -355,7 +355,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
}
);
this._endDate = calcDate(
- ev.detail.endDate,
+ ev.detail.value.endDate,
endOfDay,
this.hass.locale,
this.hass.config,
diff --git a/src/panels/lovelace/components/hui-generic-entity-row.ts b/src/panels/lovelace/components/hui-generic-entity-row.ts
index dfa86f40f5..1b7b2ac587 100644
--- a/src/panels/lovelace/components/hui-generic-entity-row.ts
+++ b/src/panels/lovelace/components/hui-generic-entity-row.ts
@@ -201,9 +201,7 @@ export class HuiGenericEntityRow extends LitElement {
padding-inline-end: 8px;
flex: 1 1 30%;
min-height: 40px;
- display: flex;
- flex-direction: column;
- justify-content: center;
+ align-content: center;
}
.info,
.info > * {
@@ -238,8 +236,7 @@ export class HuiGenericEntityRow extends LitElement {
.value {
direction: ltr;
min-height: 40px;
- display: flex;
- align-items: center;
+ align-content: center;
}
`;
}
diff --git a/src/panels/media-browser/ha-bar-media-player.ts b/src/panels/media-browser/ha-bar-media-player.ts
index db3b3ccf23..be5f24ce26 100644
--- a/src/panels/media-browser/ha-bar-media-player.ts
+++ b/src/panels/media-browser/ha-bar-media-player.ts
@@ -462,7 +462,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
}
private _openMoreInfo() {
- if (this._browserPlayer) {
+ if (this.entityId === BROWSER_PLAYER) {
return;
}
fireEvent(this, "hass-more-info", { entityId: this.entityId });
diff --git a/src/state/logging-mixin.ts b/src/state/logging-mixin.ts
index 79e07ccd63..8ba4b630b3 100644
--- a/src/state/logging-mixin.ts
+++ b/src/state/logging-mixin.ts
@@ -29,21 +29,11 @@ export const loggingMixin = >(
return;
}
if (
- // !__DEV__ &&
- ev.message.includes("ResizeObserver loop limit exceeded") ||
+ (!__DEV__ &&
+ ev.message.includes("ResizeObserver loop limit exceeded")) ||
ev.message.includes(
"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.stopImmediatePropagation();
diff --git a/src/translations/en.json b/src/translations/en.json
index 70bace21f2..8271cb33d7 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -2223,7 +2223,8 @@
"backup_type": "Type",
"type": {
"manual": "Manual",
- "automatic": "Automatic"
+ "automatic": "Automatic",
+ "addon_update": "Add-on update"
},
"locations": "Locations",
"create": {
@@ -2392,17 +2393,23 @@
"download": {
"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.",
- "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_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": {
"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.",
"network_mount_agent_description": "Network storage",
+ "unavailable_agents": "Unavailable locations",
"no_agents": "No locations configured",
"encryption_turned_off": "Encryption turned off",
"local_agent": "This system"
@@ -2560,6 +2567,7 @@
"title": "My backups",
"automatic": "{count} automatic {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",
"show_all": "Show all backups"
},
@@ -2678,19 +2686,19 @@
"encryption": {
"title": "Encryption",
"description": "All your backups are encrypted by default to keep your data private and secure.",
- "location_encrypted": "This location is encrypted",
- "location_unencrypted": "This location is unencrypted",
- "location_encrypted_description": "Your data private and secure by securing it with your encryption key.",
+ "location_encrypted": "Backups made to this location will be encrypted",
+ "location_unencrypted": "Backups made to this location will be unencrypted",
+ "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_learn_more": "Learn more",
"location_unencrypted_description": "Please keep your backups private and secure.",
"encryption_turn_on": "Turn on",
"encryption_turn_off": "Turn 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",
"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": {
+ "download_support_package": "Download support package",
"reset_cloud_data": "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.",
@@ -5330,6 +5339,8 @@
"name": "Name",
"source": "Source",
"rssi": "RSSI",
+ "source_address": "Source address",
+ "device": "Device",
"device_information": "Device information",
"advertisement_data": "Advertisement data",
"manufacturer_data": "Manufacturer data",
diff --git a/src/util/text.ts b/src/util/text.ts
index b332c2ec0a..edd6afdd08 100644
--- a/src/util/text.ts
+++ b/src/util/text.ts
@@ -21,5 +21,11 @@ export function measureTextWidth(
}
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
+ )
+ );
}