20250205.0 (#24088)

This commit is contained in:
Bram Kragten 2025-02-05 16:38:06 +01:00 committed by GitHub
commit 9d7d332790
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 2095 additions and 836 deletions

View File

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

View File

@ -1,3 +1,4 @@
import memoizeOne from "memoize-one";
import { theme2hex } from "./convert-color"; import { theme2hex } from "./convert-color";
export const COLORS = [ export const COLORS = [
@ -74,3 +75,12 @@ export function getGraphColorByIndex(
getColorByIndex(index); getColorByIndex(index);
return theme2hex(themeColor); return theme2hex(themeColor);
} }
export const getAllGraphColors = memoizeOne(
(style: CSSStyleDeclaration) =>
COLORS.map((_color, index) => getGraphColorByIndex(index, style)),
(newArgs: [CSSStyleDeclaration], lastArgs: [CSSStyleDeclaration]) =>
// this is not ideal, but we need to memoize the colors
newArgs[0].getPropertyValue("--graph-color-1") ===
lastArgs[0].getPropertyValue("--graph-color-1")
);

View File

@ -1,5 +1,4 @@
import type { HassConfig } from "home-assistant-js-websocket"; import type { HassConfig } from "home-assistant-js-websocket";
import type { XAXisOption } from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../data/translation"; import type { FrontendLocaleData } from "../../data/translation";
import { import {
formatDateMonth, formatDateMonth,
@ -7,56 +6,46 @@ import {
formatDateVeryShort, formatDateVeryShort,
formatDateWeekdayShort, formatDateWeekdayShort,
} from "../../common/datetime/format_date"; } from "../../common/datetime/format_date";
import { formatTime } from "../../common/datetime/format_time"; import {
formatTime,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
export function getLabelFormatter( export function formatTimeLabel(
value: number | Date,
locale: FrontendLocaleData, locale: FrontendLocaleData,
config: HassConfig, config: HassConfig,
dayDifference = 0 minutesDifference: number
) { ) {
return (value: number | Date) => { const dayDifference = minutesDifference / 60 / 24;
const date = new Date(value); const date = new Date(value);
if (dayDifference > 88) { if (dayDifference > 88) {
return date.getMonth() === 0 return date.getMonth() === 0
? `{bold|${formatDateMonthYear(date, locale, config)}}` ? `{bold|${formatDateMonthYear(date, locale, config)}}`
: formatDateMonth(date, locale, config); : formatDateMonth(date, locale, config);
} }
if (dayDifference > 35) { if (dayDifference > 35) {
return date.getDate() === 1 return date.getDate() === 1
? `{bold|${formatDateVeryShort(date, locale, config)}}` ? `{bold|${formatDateVeryShort(date, locale, config)}}`
: formatDateVeryShort(date, locale, config); : formatDateVeryShort(date, locale, config);
} }
if (dayDifference > 7) { if (dayDifference > 7) {
const label = formatDateVeryShort(date, locale, config); const label = formatDateVeryShort(date, locale, config);
return date.getDate() === 1 ? `{bold|${label}}` : label; return date.getDate() === 1 ? `{bold|${label}}` : label;
} }
if (dayDifference > 2) { if (dayDifference > 2) {
return formatDateWeekdayShort(date, locale, config); return formatDateWeekdayShort(date, locale, config);
} }
if (minutesDifference && minutesDifference < 5) {
return formatTimeWithSeconds(date, locale, config);
}
if (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0
) {
// show only date for the beginning of the day // show only date for the beginning of the day
if ( return `{bold|${formatDateVeryShort(date, locale, config)}}`;
date.getHours() === 0 && }
date.getMinutes() === 0 && return formatTime(date, locale, config);
date.getSeconds() === 0
) {
return `{bold|${formatDateVeryShort(date, locale, config)}}`;
}
return formatTime(date, locale, config);
};
}
export function getTimeAxisLabelConfig(
locale: FrontendLocaleData,
config: HassConfig,
dayDifference?: number
): XAXisOption["axisLabel"] {
return {
formatter: getLabelFormatter(locale, config, dayDifference),
rich: {
bold: {
fontWeight: "bold",
},
},
hideOverlap: true,
};
} }

View File

@ -1,25 +1,29 @@
import type { PropertyValues } from "lit"; import { consume } from "@lit-labs/context";
import { css, html, nothing, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { mdiRestart } from "@mdi/js";
import type { EChartsType } from "echarts/core";
import type { DataZoomComponentOption } from "echarts/components";
import { ResizeController } from "@lit-labs/observers/resize-controller"; import { ResizeController } from "@lit-labs/observers/resize-controller";
import { mdiRestart } from "@mdi/js";
import { differenceInMinutes } from "date-fns";
import type { DataZoomComponentOption } from "echarts/components";
import type { EChartsType } from "echarts/core";
import type { import type {
ECElementEvent, ECElementEvent,
XAXisOption, XAXisOption,
YAXisOption, YAXisOption,
} from "echarts/types/dist/shared"; } from "echarts/types/dist/shared";
import { consume } from "@lit-labs/context"; import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { getAllGraphColors } from "../../common/color/colors";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { themesContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac"; import { isMac } from "../../util/is_mac";
import "../ha-icon-button"; import "../ha-icon-button";
import type { ECOption } from "../../resources/echarts"; import { formatTimeLabel } from "./axis-label";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { Themes } from "../../data/ws-themes";
import { themesContext } from "../../data/context";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
@ -44,6 +48,10 @@ export class HaChartBase extends LitElement {
@state() private _isZoomed = false; @state() private _isZoomed = false;
@state() private _zoomRatio = 1;
@state() private _minutesDifference = 24 * 60;
private _modifierPressed = false; private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window; private _isTouchDevice = "ontouchstart" in window;
@ -135,16 +143,7 @@ export class HaChartBase extends LitElement {
this.chart.setOption(this._createOptions(), { this.chart.setOption(this._createOptions(), {
lazyUpdate: true, lazyUpdate: true,
// if we replace the whole object, it will reset the dataZoom // if we replace the whole object, it will reset the dataZoom
replaceMerge: [ replaceMerge: ["grid"],
"xAxis",
"yAxis",
"dataZoom",
"dataset",
"tooltip",
"legend",
"grid",
"visualMap",
],
}); });
} }
} }
@ -152,7 +151,10 @@ export class HaChartBase extends LitElement {
protected render() { protected render() {
return html` return html`
<div <div
class="chart-container" class=${classMap({
"chart-container": true,
"has-legend": !!this.options?.legend,
})}
style=${styleMap({ style=${styleMap({
height: this.height ?? `${this._getDefaultHeight()}px`, height: this.height ?? `${this._getDefaultHeight()}px`,
})} })}
@ -173,6 +175,14 @@ export class HaChartBase extends LitElement {
`; `;
} }
private _formatTimeLabel = (value: number | Date) =>
formatTimeLabel(
value,
this.hass.locale,
this.hass.config,
this._minutesDifference * this._zoomRatio
);
private async _setupChart() { private async _setupChart() {
if (this._loading) return; if (this._loading) return;
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement; const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
@ -183,10 +193,9 @@ export class HaChartBase extends LitElement {
} }
const echarts = (await import("../../resources/echarts")).default; const echarts = (await import("../../resources/echarts")).default;
this.chart = echarts.init( echarts.registerTheme("custom", this._createTheme());
container,
this._themes.darkMode ? "dark" : "light" this.chart = echarts.init(container, "custom");
);
this.chart.on("legendselectchanged", (params: any) => { this.chart.on("legendselectchanged", (params: any) => {
if (this.externalHidden) { if (this.externalHidden) {
const isSelected = params.selected[params.name]; const isSelected = params.selected[params.name];
@ -200,6 +209,7 @@ export class HaChartBase extends LitElement {
this.chart.on("datazoom", (e: any) => { this.chart.on("datazoom", (e: any) => {
const { start, end } = e.batch?.[0] ?? e; const { start, end } = e.batch?.[0] ?? e;
this._isZoomed = start !== 0 || end !== 100; this._isZoomed = start !== 0 || end !== 100;
this._zoomRatio = (end - start) / 100;
}); });
this.chart.on("click", (e: ECElementEvent) => { this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e); fireEvent(this, "chart-click", e);
@ -237,24 +247,60 @@ export class HaChartBase extends LitElement {
} }
private _createOptions(): ECOption { private _createOptions(): ECOption {
const darkMode = this._themes.darkMode ?? false; let xAxis = this.options?.xAxis;
if (xAxis) {
xAxis = Array.isArray(xAxis) ? xAxis : [xAxis];
xAxis = xAxis.map((axis: XAXisOption) => {
if (axis.type !== "time" || axis.show === false) {
return axis;
}
if (axis.max && axis.min) {
this._minutesDifference = differenceInMinutes(
axis.max as Date,
axis.min as Date
);
}
const dayDifference = this._minutesDifference / 60 / 24;
let minInterval: number | undefined;
if (dayDifference) {
minInterval =
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined;
}
return {
axisLine: {
show: false,
},
splitLine: {
show: true,
},
...axis,
axisLabel: {
formatter: this._formatTimeLabel,
rich: {
bold: {
fontWeight: "bold",
},
},
hideOverlap: true,
...axis.axisLabel,
},
minInterval,
} as XAXisOption;
});
}
const options = { const options = {
backgroundColor: "transparent",
animation: !this._reducedMotion, animation: !this._reducedMotion,
darkMode, darkMode: this._themes.darkMode ?? false,
aria: { aria: {
show: true, show: true,
}, },
dataZoom: this._getDataZoomConfig(), dataZoom: this._getDataZoomConfig(),
...this.options, ...this.options,
legend: this.options?.legend xAxis,
? {
// we should create our own theme but this is a quick fix for now
inactiveColor: darkMode ? "#444" : "#ccc",
...this.options.legend,
}
: undefined,
}; };
const isMobile = window.matchMedia( const isMobile = window.matchMedia(
@ -268,18 +314,206 @@ export class HaChartBase extends LitElement {
tooltips.forEach((tooltip) => { tooltips.forEach((tooltip) => {
tooltip.confine = true; tooltip.confine = true;
tooltip.appendTo = undefined; tooltip.appendTo = undefined;
tooltip.triggerOn = "click";
}); });
options.tooltip = tooltips; options.tooltip = tooltips;
} }
return options; return options;
} }
private _createTheme() {
const style = getComputedStyle(this);
return {
color: getAllGraphColors(style),
backgroundColor: "transparent",
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
},
title: {
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
},
subtextStyle: {
color: style.getPropertyValue("--secondary-text-color"),
},
},
line: {
lineStyle: {
width: 1.5,
},
symbolSize: 1,
symbol: "circle",
smooth: false,
},
bar: {
itemStyle: {
barBorderWidth: 1.5,
},
},
categoryAxis: {
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: false,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
valueAxis: {
axisLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisTick: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
logAxis: {
axisLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisTick: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
timeAxis: {
axisLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisTick: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
legend: {
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
},
inactiveColor: style.getPropertyValue("--disabled-text-color"),
pageIconColor: style.getPropertyValue("--primary-text-color"),
pageIconInactiveColor: style.getPropertyValue("--disabled-text-color"),
pageTextStyle: {
color: style.getPropertyValue("--secondary-text-color"),
},
},
tooltip: {
backgroundColor: style.getPropertyValue("--card-background-color"),
borderColor: style.getPropertyValue("--divider-color"),
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
fontSize: 12,
},
axisPointer: {
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
crossStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
},
timeline: {},
};
}
private _getDefaultHeight() { private _getDefaultHeight() {
return Math.max(this.clientWidth / 2, 400); return Math.max(this.clientWidth / 2, 200);
} }
private _handleZoomReset() { private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 }); this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
this._modifierPressed = false;
} }
private _handleWheel(e: WheelEvent) { private _handleWheel(e: WheelEvent) {
@ -302,10 +536,11 @@ export class HaChartBase extends LitElement {
:host { :host {
display: block; display: block;
position: relative; position: relative;
letter-spacing: normal;
} }
.chart-container { .chart-container {
position: relative; position: relative;
max-height: var(--chart-max-height, 400px); max-height: var(--chart-max-height, 350px);
} }
.chart { .chart {
width: 100%; width: 100%;
@ -321,6 +556,9 @@ export class HaChartBase extends LitElement {
color: var(--primary-color); color: var(--primary-color);
border: 1px solid var(--divider-color); border: 1px solid var(--divider-color);
} }
.has-legend .zoom-reset {
top: 64px;
}
`; `;
} }

View File

@ -4,7 +4,6 @@ import { property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components"; import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts"; import type { LineSeriesOption } from "echarts/charts";
import type { YAXisOption } from "echarts/types/dist/shared"; import type { YAXisOption } from "echarts/types/dist/shared";
import { differenceInDays } from "date-fns";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { getGraphColorByIndex } from "../../common/color/colors"; import { getGraphColorByIndex } from "../../common/color/colors";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
@ -18,10 +17,10 @@ import {
getNumberFormatOptions, getNumberFormatOptions,
formatNumber, formatNumber,
} from "../../common/number/format_number"; } from "../../common/number/format_number";
import { getTimeAxisLabelConfig } from "./axis-label";
import { measureTextWidth } from "../../util/text"; import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate"; import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
const safeParseFloat = (value) => { const safeParseFloat = (value) => {
const parsed = parseFloat(value); const parsed = parseFloat(value);
@ -72,6 +71,8 @@ export class StateHistoryChartLine extends LitElement {
@state() private _chartOptions?: ECOption; @state() private _chartOptions?: ECOption;
private _hiddenStats = new Set<string>();
@state() private _yWidth = 25; @state() private _yWidth = 25;
private _chartTime: Date = new Date(); private _chartTime: Date = new Date();
@ -84,49 +85,104 @@ export class StateHistoryChartLine extends LitElement {
.options=${this._chartOptions} .options=${this._chartOptions}
.height=${this.height} .height=${this.height}
style=${styleMap({ height: this.height })} style=${styleMap({ height: this.height })}
external-hidden
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
></ha-chart-base> ></ha-chart-base>
`; `;
} }
private _renderTooltip(params) { private _renderTooltip(params: any) {
return params const time = params[0].axisValue;
.map((param, index: number) => { const title =
let value = `${formatNumber( formatDateTimeWithSeconds(
param.value[1] as number, new Date(time),
this.hass.locale, this.hass.locale,
getNumberFormatOptions( this.hass.config
undefined, ) + "<br>";
this.hass.entities[this._entityIds[param.seriesIndex]] const datapoints: Record<string, any>[] = [];
) this._chartData.forEach((dataset, index) => {
)} ${this.unit}`; if (
const dataIndex = this._datasetToDataIndex[param.seriesIndex]; dataset.tooltip?.show === false ||
const data = this.data[dataIndex]; this._hiddenStats.has(dataset.name as string)
if (data.statistics && data.statistics.length > 0) { )
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"; return;
const source = const param = params.find(
data.states.length === 0 || (p: Record<string, any>) => p.seriesIndex === index
param.value[0] < data.states[0].last_changed );
? `${this.hass.localize( if (param) {
"ui.components.history_charts.source_stats" datapoints.push(param);
)}` return;
: `${this.hass.localize( }
"ui.components.history_charts.source_history" // If the datapoint is not found, we need to find the last datapoint before the current time
)}`; let lastData;
value += source; const data = dataset.data || [];
for (let i = data.length - 1; i >= 0; i--) {
const point = data[i];
if (point && point[0] <= time && point[1]) {
lastData = point;
break;
} }
}
if (!lastData) return;
datapoints.push({
seriesName: dataset.name,
seriesIndex: index,
value: lastData,
// HTML copied from echarts. May change based on options
marker: `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
});
});
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
const time = return (
index === 0 title +
? formatDateTimeWithSeconds( datapoints
new Date(param.value[0]), .map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
let value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale, this.hass.locale,
this.hass.config getNumberFormatOptions(undefined, entry)
) + "<br>" )}${unit}`;
: ""; const dataIndex = this._datasetToDataIndex[param.seriesIndex];
return `${time}${param.marker} ${param.seriesName}: ${value} const data = this.data[dataIndex];
`; if (data.statistics && data.statistics.length > 0) {
}) value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
.join("<br>"); const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
if (param.seriesName) {
return `${param.marker} ${param.seriesName}: ${value}`;
}
return `${param.marker} ${value}`;
})
.join("<br>")
);
}
private _datasetHidden(ev: CustomEvent) {
this._hiddenStats.add(ev.detail.name);
}
private _datasetUnhidden(ev: CustomEvent) {
this._hiddenStats.delete(ev.detail.name);
} }
public willUpdate(changedProps: PropertyValues) { public willUpdate(changedProps: PropertyValues) {
@ -156,49 +212,44 @@ export class StateHistoryChartLine extends LitElement {
changedProps.has("paddingYAxis") || changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth") changedProps.has("_yWidth")
) { ) {
const dayDifference = differenceInDays(this.endTime, this.startTime);
const rtl = computeRTL(this.hass); const rtl = computeRTL(this.hass);
const splitLineStyle = this.hass.themes?.darkMode let minYAxis: number | ((values: { min: number }) => number) | undefined =
? { opacity: 0.15 } this.minYAxis;
: {}; let maxYAxis: number | ((values: { max: number }) => number) | undefined =
this.maxYAxis;
if (typeof minYAxis === "number") {
if (this.fitYData) {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => (min > 0 ? min * 0.95 : min * 1.05);
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => (max > 0 ? max * 1.05 : max * 0.95);
}
this._chartOptions = { this._chartOptions = {
xAxis: { xAxis: {
type: "time", type: "time",
min: this.startTime, min: this.startTime,
max: this.endTime, max: this.endTime,
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
axisLine: {
show: false,
},
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
}, },
yAxis: { yAxis: {
type: this.logarithmicScale ? "log" : "value", type: this.logarithmicScale ? "log" : "value",
name: this.unit, name: this.unit,
min: this.fitYData ? this.minYAxis : undefined, min: this._clampYAxis(minYAxis),
max: this.fitYData ? this.maxYAxis : undefined, max: this._clampYAxis(maxYAxis),
position: rtl ? "right" : "left", position: rtl ? "right" : "left",
scale: true, scale: true,
nameGap: 2, nameGap: 2,
nameTextStyle: { nameTextStyle: {
align: "left", align: "left",
}, },
splitLine: { axisLine: {
show: true, show: false,
lineStyle: splitLineStyle,
}, },
axisLabel: { axisLabel: {
margin: 5, margin: 5,
@ -218,6 +269,8 @@ export class StateHistoryChartLine extends LitElement {
} as YAXisOption, } as YAXisOption,
legend: { legend: {
show: this.showNames, show: this.showNames,
type: "scroll",
animationDurationUpdate: 400,
icon: "circle", icon: "circle",
padding: [20, 0], padding: [20, 0],
}, },
@ -307,13 +360,18 @@ export class StateHistoryChartLine extends LitElement {
prevValues = datavalues; prevValues = datavalues;
}; };
const addDataSet = (nameY: string, color?: string, fill = false) => { const addDataSet = (
id: string,
nameY: string,
color?: string,
fill = false
) => {
if (!color) { if (!color) {
color = getGraphColorByIndex(colorIndex, computedStyles); color = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++; colorIndex++;
} }
data.push({ data.push({
id: nameY, id,
data: [], data: [],
type: "line", type: "line",
cursor: "default", cursor: "default",
@ -321,6 +379,7 @@ export class StateHistoryChartLine extends LitElement {
color, color,
symbol: "circle", symbol: "circle",
step: "end", step: "end",
animationDurationUpdate: 0,
symbolSize: 1, symbolSize: 1,
lineStyle: { lineStyle: {
width: fill ? 0 : 1.5, width: fill ? 0 : 1.5,
@ -375,13 +434,23 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.target_temp_low entityState.attributes.target_temp_low
); );
addDataSet( addDataSet(
`${this.hass.localize("ui.card.climate.current_temperature", { states.entity_id + "-current_temperature",
name: name, this.showNames
})}` ? this.hass.localize("ui.card.climate.current_temperature", {
name: name,
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
); );
if (hasHeat) { if (hasHeat) {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.climate.heating", { name: name })}`, states.entity_id + "-heating",
this.showNames
? this.hass.localize("ui.card.climate.heating", { name: name })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.heating"
),
computedStyles.getPropertyValue("--state-climate-heat-color"), computedStyles.getPropertyValue("--state-climate-heat-color"),
true true
); );
@ -390,7 +459,12 @@ export class StateHistoryChartLine extends LitElement {
} }
if (hasCool) { if (hasCool) {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.climate.cooling", { name: name })}`, states.entity_id + "-cooling",
this.showNames
? this.hass.localize("ui.card.climate.cooling", { name: name })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.cooling"
),
computedStyles.getPropertyValue("--state-climate-cool-color"), computedStyles.getPropertyValue("--state-climate-cool-color"),
true true
); );
@ -400,22 +474,40 @@ export class StateHistoryChartLine extends LitElement {
if (hasTargetRange) { if (hasTargetRange) {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.climate.target_temperature_mode", { states.entity_id + "-target_temperature_mode",
name: name, this.showNames
mode: this.hass.localize("ui.card.climate.high"), ? this.hass.localize("ui.card.climate.target_temperature_mode", {
})}` name: name,
mode: this.hass.localize("ui.card.climate.high"),
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_high.name"
)
); );
addDataSet( addDataSet(
`${this.hass.localize("ui.card.climate.target_temperature_mode", { states.entity_id + "-target_temperature_mode_low",
name: name, this.showNames
mode: this.hass.localize("ui.card.climate.low"), ? this.hass.localize("ui.card.climate.target_temperature_mode", {
})}` name: name,
mode: this.hass.localize("ui.card.climate.low"),
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_low.name"
)
); );
} else { } else {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.climate.target_temperature_entity", { states.entity_id + "-target_temperature",
name: name, this.showNames
})}` ? this.hass.localize(
"ui.card.climate.target_temperature_entity",
{
name: name,
}
)
: this.hass.localize(
"component.climate.entity_component._.state_attributes.temperature.name"
)
); );
} }
@ -468,19 +560,29 @@ export class StateHistoryChartLine extends LitElement {
); );
addDataSet( addDataSet(
`${this.hass.localize("ui.card.humidifier.target_humidity_entity", { states.entity_id + "-target_humidity",
name: name, this.showNames
})}` ? this.hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.humidity.name"
)
); );
if (hasCurrent) { if (hasCurrent) {
addDataSet( addDataSet(
`${this.hass.localize( states.entity_id + "-current_humidity",
"ui.card.humidifier.current_humidity_entity", this.showNames
{ ? this.hass.localize(
name: name, "ui.card.humidifier.current_humidity_entity",
} {
)}` name: name,
}
)
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
)
); );
} }
@ -488,25 +590,40 @@ export class StateHistoryChartLine extends LitElement {
// If action attribute is not available, we shade the area when the device is on // If action attribute is not available, we shade the area when the device is on
if (hasHumidifying) { if (hasHumidifying) {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.humidifier.humidifying", { states.entity_id + "-humidifying",
name: name, this.showNames
})}`, ? this.hass.localize("ui.card.humidifier.humidifying", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"), computedStyles.getPropertyValue("--state-humidifier-on-color"),
true true
); );
} else if (hasDrying) { } else if (hasDrying) {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.humidifier.drying", { states.entity_id + "-drying",
name: name, this.showNames
})}`, ? this.hass.localize("ui.card.humidifier.drying", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.drying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"), computedStyles.getPropertyValue("--state-humidifier-on-color"),
true true
); );
} else { } else {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.humidifier.on_entity", { states.entity_id + "-on",
name: name, this.showNames
})}`, ? this.hass.localize("ui.card.humidifier.on_entity", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state.on"
),
undefined, undefined,
true true
); );
@ -539,7 +656,7 @@ export class StateHistoryChartLine extends LitElement {
pushData(new Date(entityState.last_changed), series); pushData(new Date(entityState.last_changed), series);
}); });
} else { } else {
addDataSet(name); addDataSet(states.entity_id, name);
let lastValue: number; let lastValue: number;
let lastDate: Date; let lastDate: Date;
@ -609,6 +726,19 @@ export class StateHistoryChartLine extends LitElement {
this._entityIds = entityIds; this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex; this._datasetToDataIndex = datasetToDataIndex;
} }
private _clampYAxis(value?: number | ((values: any) => number)) {
if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value
if (typeof value === "number") {
return Math.max(value, 0.1);
}
if (typeof value === "function") {
return (values: any) => Math.max(value(values), 0.1);
}
}
return value;
}
} }
customElements.define("state-history-chart-line", StateHistoryChartLine); customElements.define("state-history-chart-line", StateHistoryChartLine);

View File

@ -8,7 +8,6 @@ import type {
TooltipFormatterCallback, TooltipFormatterCallback,
TooltipPositionCallbackParams, TooltipPositionCallbackParams,
} from "echarts/types/dist/shared"; } from "echarts/types/dist/shared";
import { differenceInDays } from "date-fns";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration"; import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
@ -22,7 +21,6 @@ import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color"; import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text"; import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { getTimeAxisLabelConfig } from "./axis-label";
@customElement("state-history-chart-timeline") @customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement { export class StateHistoryChartTimeline extends LitElement {
@ -67,7 +65,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.options=${this._chartOptions} .options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`} .height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData} .data=${this._chartData as ECOption["series"]}
@chart-click=${this._handleChartClick} @chart-click=${this._handleChartClick}
></ha-chart-base> ></ha-chart-base>
`; `;
@ -129,10 +127,12 @@ export class StateHistoryChartTimeline extends LitElement {
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> = private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => { (params: TooltipPositionCallbackParams) => {
const { value, name, marker } = Array.isArray(params) const { value, name, marker, seriesName } = Array.isArray(params)
? params[0] ? params[0]
: params; : params;
const title = `<h4 style="text-align: center; margin: 0;">${value![0]}</h4>`; const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const durationInMs = value![2] - value![1]; const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize( const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration" "ui.components.history_charts.duration"
@ -183,13 +183,12 @@ export class StateHistoryChartTimeline extends LitElement {
private _createOptions() { private _createOptions() {
const narrow = this.narrow; const narrow = this.narrow;
const showNames = this.chunked || this.showNames; const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 70 : 165; const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth) ? Math.max(this.paddingYAxis, this._yWidth)
: 0; : 0;
const labelMargin = 5; const labelMargin = 5;
const rtl = computeRTL(this.hass); const rtl = computeRTL(this.hass);
const dayDifference = differenceInDays(this.endTime, this.startTime);
this._chartOptions = { this._chartOptions = {
xAxis: { xAxis: {
type: "time", type: "time",
@ -197,21 +196,10 @@ export class StateHistoryChartTimeline extends LitElement {
max: this.endTime, max: this.endTime,
axisTick: { axisTick: {
show: true, show: true,
lineStyle: {
opacity: 0.4,
},
}, },
axisLabel: getTimeAxisLabelConfig( splitLine: {
this.hass.locale, show: false,
this.hass.config, },
dayDifference
),
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
}, },
yAxis: { yAxis: {
type: "category", type: "category",
@ -226,14 +214,18 @@ export class StateHistoryChartTimeline extends LitElement {
}, },
axisLabel: { axisLabel: {
show: showNames, show: showNames,
width: labelWidth - labelMargin, width: labelWidth,
overflow: "truncate", overflow: "truncate",
margin: labelMargin, margin: labelMargin,
formatter: (label: string) => { formatter: (id: string) => {
const width = Math.min( const label = this._chartData.find((d) => d.id === id)
measureTextWidth(label, 12) + labelMargin, ?.name as string;
maxInternalLabelWidth const width = label
); ? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) { if (width > this._yWidth) {
this._yWidth = width; this._yWidth = width;
fireEvent(this, "y-width-changed", { fireEvent(this, "y-width-changed", {
@ -278,8 +270,9 @@ export class StateHistoryChartTimeline extends LitElement {
let prevState: string | null = null; let prevState: string | null = null;
let locState: string | null = null; let locState: string | null = null;
let prevLastChanged = startTime; let prevLastChanged = startTime;
const entityDisplay: string = const entityDisplay: string = this.showNames
names[stateInfo.entity_id] || stateInfo.name; ? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
: "";
const dataRow: unknown[] = []; const dataRow: unknown[] = [];
stateInfo.data.forEach((entityState) => { stateInfo.data.forEach((entityState) => {
@ -307,7 +300,7 @@ export class StateHistoryChartTimeline extends LitElement {
); );
dataRow.push({ dataRow.push({
value: [ value: [
entityDisplay, stateInfo.entity_id,
prevLastChanged, prevLastChanged,
newLastChanged, newLastChanged,
locState, locState,
@ -333,7 +326,7 @@ export class StateHistoryChartTimeline extends LitElement {
); );
dataRow.push({ dataRow.push({
value: [ value: [
entityDisplay, stateInfo.entity_id,
prevLastChanged, prevLastChanged,
endTime, endTime,
locState, locState,
@ -346,9 +339,10 @@ export class StateHistoryChartTimeline extends LitElement {
}); });
} }
datasets.push({ datasets.push({
id: stateInfo.entity_id,
data: dataRow, data: dataRow,
name: entityDisplay, name: entityDisplay,
dimensions: ["index", "start", "end", "name", "color", "textColor"], dimensions: ["id", "start", "end", "name", "color", "textColor"],
type: "custom", type: "custom",
encode: { encode: {
x: [1, 2], x: [1, 2],
@ -364,10 +358,10 @@ export class StateHistoryChartTimeline extends LitElement {
private _handleChartClick(e: CustomEvent<ECElementEvent>): void { private _handleChartClick(e: CustomEvent<ECElementEvent>): void {
if (e.detail.targetType === "axisLabel") { if (e.detail.targetType === "axisLabel") {
const dataset = this.data[e.detail.dataIndex]; const dataset = this._chartData[e.detail.dataIndex];
if (dataset) { if (dataset) {
fireEvent(this, "hass-more-info", { fireEvent(this, "hass-more-info", {
entityId: dataset.entity_id, entityId: dataset.id as string,
}); });
} }
} }

View File

@ -135,7 +135,7 @@ export class StateHistoryCharts extends LitElement {
return html``; return html``;
} }
if (!Array.isArray(item)) { if (!Array.isArray(item)) {
return html`<div class="entry-container"> return html`<div class="entry-container line">
<state-history-chart-line <state-history-chart-line
.hass=${this.hass} .hass=${this.hass}
.unit=${item.unit} .unit=${item.unit}
@ -157,7 +157,7 @@ export class StateHistoryCharts extends LitElement {
></state-history-chart-line> ></state-history-chart-line>
</div> `; </div> `;
} }
return html`<div class="entry-container"> return html`<div class="entry-container timeline">
<state-history-chart-timeline <state-history-chart-timeline
.hass=${this.hass} .hass=${this.hass}
.data=${item} .data=${item}
@ -299,6 +299,9 @@ export class StateHistoryCharts extends LitElement {
.entry-container { .entry-container {
width: 100%; width: 100%;
}
.entry-container.line {
flex: 1; flex: 1;
} }
@ -313,6 +316,10 @@ export class StateHistoryCharts extends LitElement {
padding-inline-end: 1px; padding-inline-end: 1px;
} }
.entry-container.timeline:first-child {
margin-top: var(--timeline-top-margin);
}
.entry-container:not(:first-child) { .entry-container:not(:first-child) {
border-top: 2px solid var(--divider-color); border-top: 2px solid var(--divider-color);
margin-top: 16px; margin-top: 16px;

View File

@ -1,15 +1,22 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { import type {
BarSeriesOption, BarSeriesOption,
LineSeriesOption, LineSeriesOption,
} from "echarts/types/dist/shared"; } from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors"; import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
formatNumber,
getNumberFormatOptions,
} from "../../common/number/format_number";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { computeRTL } from "../../common/util/compute_rtl";
import type { import type {
Statistics, Statistics,
StatisticsMetaData, StatisticsMetaData,
@ -21,16 +28,9 @@ import {
getStatisticMetadata, getStatisticMetadata,
statisticsHaveType, statisticsHaveType,
} from "../../data/recorder"; } from "../../data/recorder";
import type { ECOption } from "../../resources/echarts";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "./ha-chart-base"; import "./ha-chart-base";
import { computeRTL } from "../../common/util/compute_rtl";
import type { ECOption } from "../../resources/echarts";
import {
formatNumber,
getNumberFormatOptions,
} from "../../common/number/format_number";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { getTimeAxisLabelConfig } from "./axis-label";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = { export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean", mean: "mean",
@ -56,6 +56,8 @@ export class StatisticsChart extends LitElement {
@property() public unit?: string; @property() public unit?: string;
@property({ attribute: false }) public startTime?: Date;
@property({ attribute: false }) public endTime?: Date; @property({ attribute: false }) public endTime?: Date;
@property({ attribute: false, type: Array }) @property({ attribute: false, type: Array })
@ -124,7 +126,10 @@ export class StatisticsChart extends LitElement {
changedProps.has("fitYData") || changedProps.has("fitYData") ||
changedProps.has("logarithmicScale") || changedProps.has("logarithmicScale") ||
changedProps.has("hideLegend") || changedProps.has("hideLegend") ||
changedProps.has("_legendData") changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("_legendData") ||
changedProps.has("_chartData")
) { ) {
this._createOptions(); this._createOptions();
} }
@ -181,18 +186,31 @@ export class StatisticsChart extends LitElement {
this.requestUpdate("_hiddenStats"); this.requestUpdate("_hiddenStats");
} }
private _renderTooltip = (params: any) => private _renderTooltip = (params: any) => {
params const rendered: Record<string, boolean> = {};
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return params
.map((param, index: number) => { .map((param, index: number) => {
if (rendered[param.seriesName]) return "";
rendered[param.seriesName] = true;
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
// max series can have 3 values, as the second value is the max-min to form a band
const rawValue = String(param.value[2] ?? param.value[1]);
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber( const value = `${formatNumber(
// max series can have 3 values, as the second value is the max-min to form a band rawValue,
(param.value[2] ?? param.value[1]) as number,
this.hass.locale, this.hass.locale,
getNumberFormatOptions( options
undefined, )}${unit}`;
this.hass.entities[this._statisticIds[param.seriesIndex]]
)
)} ${this.unit}`;
const time = const time =
index === 0 index === 0
@ -202,36 +220,68 @@ export class StatisticsChart extends LitElement {
this.hass.config this.hass.config
) + "<br>" ) + "<br>"
: ""; : "";
return `${time}${param.marker} ${param.seriesName}: ${value} return `${time}${param.marker} ${param.seriesName}: ${value}`;
`;
}) })
.filter(Boolean)
.join("<br>"); .join("<br>");
};
private _createOptions() { private _createOptions() {
const splitLineStyle = this.hass.themes?.darkMode ? { opacity: 0.15 } : {};
const dayDifference = this.daysToShow ?? 1; const dayDifference = this.daysToShow ?? 1;
let minYAxis: number | ((values: { min: number }) => number) | undefined =
this.minYAxis;
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
this.maxYAxis;
if (typeof minYAxis === "number") {
if (this.fitYData) {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => (min > 0 ? min * 0.95 : min * 1.05);
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => (max > 0 ? max * 1.05 : max * 0.95);
}
const endTime = this.endTime ?? new Date();
let startTime = this.startTime;
if (!startTime) {
// set start time to the earliest point in the chart data
this._chartData.forEach((series) => {
if (!Array.isArray(series.data) || !series.data[0]) return;
const firstPoint = series.data[0] as any;
const timestamp = Array.isArray(firstPoint)
? firstPoint[0]
: firstPoint.value?.[0];
if (timestamp && (!startTime || new Date(timestamp) < startTime)) {
startTime = new Date(timestamp);
}
});
if (!startTime) {
// Calculate default start time based on dayDifference
startTime = new Date(
endTime.getTime() - dayDifference * 24 * 3600 * 1000
);
}
}
this._chartOptions = { this._chartOptions = {
xAxis: { xAxis: [
type: "time", {
axisLabel: getTimeAxisLabelConfig( type: "time",
this.hass.locale, min: startTime,
this.hass.config, max: endTime,
dayDifference },
), {
axisLine: { type: "time",
show: false, show: false,
}, },
splitLine: { ],
show: true,
lineStyle: splitLineStyle,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: { yAxis: {
type: this.logarithmicScale ? "log" : "value", type: this.logarithmicScale ? "log" : "value",
name: this.unit, name: this.unit,
@ -241,23 +291,24 @@ export class StatisticsChart extends LitElement {
}, },
position: computeRTL(this.hass) ? "right" : "left", position: computeRTL(this.hass) ? "right" : "left",
// @ts-ignore // @ts-ignore
scale: this.chartType !== "bar", scale: true,
min: this.fitYData ? undefined : this.minYAxis, min: this._clampYAxis(minYAxis),
max: this.fitYData ? undefined : this.maxYAxis, max: this._clampYAxis(maxYAxis),
splitLine: { splitLine: {
show: true, show: true,
lineStyle: splitLineStyle,
}, },
}, },
legend: { legend: {
show: !this.hideLegend, show: !this.hideLegend,
type: "scroll",
animationDurationUpdate: 400,
icon: "circle", icon: "circle",
padding: [20, 0], padding: [20, 0],
data: this._legendData, data: this._legendData,
}, },
grid: { grid: {
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0 ...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
left: 20, left: 1,
right: 1, right: 1,
bottom: 0, bottom: 0,
containLabel: true, containLabel: true,
@ -318,6 +369,7 @@ export class StatisticsChart extends LitElement {
if (endTime > new Date()) { if (endTime > new Date()) {
endTime = new Date(); endTime = new Date();
} }
this.endTime = endTime;
let unit: string | undefined | null; let unit: string | undefined | null;
@ -369,10 +421,12 @@ export class StatisticsChart extends LitElement {
) { ) {
// if the end of the previous data doesn't match the start of the current data, // if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value. // we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]); d.data!.push(
this._transformDataValue([prevEndTime, ...prevValues[i]!])
);
d.data!.push([prevEndTime, null]); d.data!.push([prevEndTime, null]);
} }
d.data!.push([start, ...dataValues[i]!]); d.data!.push(this._transformDataValue([start, ...dataValues[i]!]));
}); });
prevValues = dataValues; prevValues = dataValues;
prevEndTime = end; prevEndTime = end;
@ -421,9 +475,14 @@ export class StatisticsChart extends LitElement {
displayedLegend = displayedLegend || showLegend; displayedLegend = displayedLegend || showLegend;
} }
statTypes.push(type); statTypes.push(type);
const borderColor =
band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color;
const backgroundColor = band ? color + "3F" : color + "7F";
const series: LineSeriesOption | BarSeriesOption = { const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`, id: `${statistic_id}-${type}`,
type: this.chartType, type: this.chartType,
smooth: this.chartType === "line" ? 0.4 : false,
smoothMonotone: "x",
cursor: "default", cursor: "default",
data: [], data: [],
name: name name: name
@ -435,6 +494,7 @@ export class StatisticsChart extends LitElement {
), ),
symbol: "circle", symbol: "circle",
symbolSize: 0, symbolSize: 0,
animationDurationUpdate: 0,
lineStyle: { lineStyle: {
width: 1.5, width: 1.5,
}, },
@ -442,21 +502,16 @@ export class StatisticsChart extends LitElement {
this.chartType === "bar" this.chartType === "bar"
? { ? {
borderRadius: [4, 4, 0, 0], borderRadius: [4, 4, 0, 0],
borderColor: borderColor,
band && hasMean
? color + (this.hideLegend ? "00" : "7F")
: color,
borderWidth: 1.5, borderWidth: 1.5,
} }
: undefined, : undefined,
color: band ? color + "3F" : color + "7F", color: this.chartType === "bar" ? backgroundColor : borderColor,
}; };
if (band && this.chartType === "line") { if (band && this.chartType === "line") {
series.stack = `band-${statistic_id}`; series.stack = `band-${statistic_id}`;
series.stackStrategy = "all";
(series as LineSeriesOption).symbol = "none"; (series as LineSeriesOption).symbol = "none";
(series as LineSeriesOption).lineStyle = {
opacity: 0,
};
if (drawBands && type === "max") { if (drawBands && type === "max") {
(series as LineSeriesOption).areaStyle = { (series as LineSeriesOption).areaStyle = {
color: color + "3F", color: color + "3F",
@ -489,7 +544,7 @@ export class StatisticsChart extends LitElement {
} }
} else if (type === "max" && this.chartType === "line") { } else if (type === "max" && this.chartType === "line") {
const max = stat.max || 0; const max = stat.max || 0;
val.push(max - (stat.min || 0)); val.push(Math.abs(max - (stat.min || 0)));
val.push(max); val.push(max);
} else { } else {
val.push(stat[type] ?? null); val.push(stat[type] ?? null);
@ -518,6 +573,7 @@ export class StatisticsChart extends LitElement {
color, color,
type: this.chartType, type: this.chartType,
data: [], data: [],
xAxisIndex: 1,
}); });
}); });
@ -529,6 +585,26 @@ export class StatisticsChart extends LitElement {
this._statisticIds = statisticIds; this._statisticIds = statisticIds;
} }
private _transformDataValue(val: [Date, ...(number | null)[]]) {
if (this.chartType === "bar" && val[1] && val[1] < 0) {
return { value: val, itemStyle: { borderRadius: [0, 0, 4, 4] } };
}
return val;
}
private _clampYAxis(value?: number | ((values: any) => number)) {
if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value
if (typeof value === "number") {
return Math.max(value, 0.1);
}
if (typeof value === "function") {
return (values: any) => Math.max(value(values), 0.1);
}
}
return value;
}
static styles = css` static styles = css`
:host { :host {
display: block; display: block;

View File

@ -329,15 +329,12 @@ export class HaBaseTimeInput extends LitElement {
:host([clearable]) { :host([clearable]) {
position: relative; position: relative;
} }
:host {
display: block;
}
.time-input-wrap-wrap { .time-input-wrap-wrap {
display: flex; display: flex;
} }
.time-input-wrap { .time-input-wrap {
display: flex; display: flex;
flex: 1; flex: var(--time-input-flex, unset);
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0; border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
overflow: hidden; overflow: hidden;
position: relative; position: relative;

View File

@ -9,12 +9,13 @@ import {
endOfMonth, endOfMonth,
endOfWeek, endOfWeek,
endOfYear, endOfYear,
isThisYear,
startOfDay, startOfDay,
startOfMonth, startOfMonth,
startOfWeek, startOfWeek,
startOfYear, startOfYear,
isThisYear,
} from "date-fns"; } from "date-fns";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import type { PropertyValues, TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
@ -22,16 +23,18 @@ import { ifDefined } from "lit/directives/if-defined";
import { calcDate, shiftDateRange } from "../common/datetime/calc_date"; import { calcDate, shiftDateRange } from "../common/datetime/calc_date";
import { firstWeekdayIndex } from "../common/datetime/first_weekday"; import { firstWeekdayIndex } from "../common/datetime/first_weekday";
import { import {
formatShortDateTimeWithYear,
formatShortDateTime, formatShortDateTime,
formatShortDateTimeWithYear,
} from "../common/datetime/format_date_time"; } from "../common/datetime/format_date_time";
import { useAmPm } from "../common/datetime/use_am_pm"; import { useAmPm } from "../common/datetime/use_am_pm";
import { fireEvent } from "../common/dom/fire_event";
import { TimeZone } from "../data/translation";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./date-range-picker"; import "./date-range-picker";
import "./ha-icon-button"; import "./ha-icon-button";
import "./ha-textarea";
import "./ha-icon-button-next"; import "./ha-icon-button-next";
import "./ha-icon-button-prev"; import "./ha-icon-button-prev";
import "./ha-textarea";
export type DateRangePickerRanges = Record<string, [Date, Date]>; export type DateRangePickerRanges = Record<string, [Date, Date]>;
@ -197,14 +200,15 @@ export class HaDateRangePicker extends LitElement {
?auto-apply=${this.autoApply} ?auto-apply=${this.autoApply}
time-picker=${this.timePicker} time-picker=${this.timePicker}
twentyfour-hours=${this._hour24format} twentyfour-hours=${this._hour24format}
start-date=${this.startDate.toISOString()} start-date=${this._formatDate(this.startDate)}
end-date=${this.endDate.toISOString()} end-date=${this._formatDate(this.endDate)}
?ranges=${this.ranges !== false} ?ranges=${this.ranges !== false}
opening-direction=${ifDefined( opening-direction=${ifDefined(
this.openingDirection || this._calcedOpeningDirection this.openingDirection || this._calcedOpeningDirection
)} )}
first-day=${firstWeekdayIndex(this.hass.locale)} first-day=${firstWeekdayIndex(this.hass.locale)}
language=${this.hass.locale.language} language=${this.hass.locale.language}
@change=${this._handleChange}
> >
<div slot="input" class="date-range-inputs" @click=${this._handleClick}> <div slot="input" class="date-range-inputs" @click=${this._handleClick}>
${!this.minimal ${!this.minimal
@ -325,9 +329,31 @@ export class HaDateRangePicker extends LitElement {
} }
private _applyDateRange() { private _applyDateRange() {
if (this.hass.locale.time_zone === TimeZone.server) {
const dateRangePicker = this._dateRangePicker;
const startDate = fromZonedTime(
dateRangePicker.start,
this.hass.config.time_zone
);
const endDate = fromZonedTime(
dateRangePicker.end,
this.hass.config.time_zone
);
dateRangePicker.clickRange([startDate, endDate]);
}
this._dateRangePicker.clickedApply(); this._dateRangePicker.clickedApply();
} }
private _formatDate(date: Date): string {
if (this.hass.locale.time_zone === TimeZone.server) {
return toZonedTime(date, this.hass.config.time_zone).toISOString();
}
return date.toISOString();
}
private get _dateRangePicker() { private get _dateRangePicker() {
const dateRangePicker = this.shadowRoot!.querySelector( const dateRangePicker = this.shadowRoot!.querySelector(
"date-range-picker" "date-range-picker"
@ -358,6 +384,16 @@ export class HaDateRangePicker extends LitElement {
} }
} }
private _handleChange(ev: CustomEvent) {
ev.stopPropagation();
const startDate = ev.detail.startDate;
const endDate = ev.detail.endDate;
fireEvent(this, "value-changed", {
value: { startDate, endDate },
});
}
static styles = css` static styles = css`
ha-icon-button { ha-icon-button {

View File

@ -1,4 +1,4 @@
import { css, html, LitElement, svg } from "lit"; import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { BRANCH_HEIGHT, SPACING } from "./hat-graph-const"; import { BRANCH_HEIGHT, SPACING } from "./hat-graph-const";
@ -41,8 +41,8 @@ export class HatGraphBranch extends LitElement {
branches.push({ branches.push({
x: width / 2 + total_width, x: width / 2 + total_width,
height, height,
start: c.hasAttribute("graphStart"), start: c.hasAttribute("graph-start"),
end: c.hasAttribute("graphEnd"), end: c.hasAttribute("graph-end"),
track: c.hasAttribute("track"), track: c.hasAttribute("track"),
}); });
total_width += width; total_width += width;
@ -65,11 +65,8 @@ export class HatGraphBranch extends LitElement {
return html` return html`
<slot name="head"></slot> <slot name="head"></slot>
${!this.start ${!this.start
? svg` ? html`
<svg <svg id="top" width=${this._totalWidth}>
id="top"
width="${this._totalWidth}"
>
${this._branches.map((branch) => ${this._branches.map((branch) =>
branch.start branch.start
? "" ? ""
@ -86,7 +83,7 @@ export class HatGraphBranch extends LitElement {
)} )}
</svg> </svg>
` `
: ""} : nothing}
<div id="branches"> <div id="branches">
<svg id="lines" width=${this._totalWidth} height=${this._maxHeight}> <svg id="lines" width=${this._totalWidth} height=${this._maxHeight}>
${this._branches.map((branch) => { ${this._branches.map((branch) => {
@ -107,11 +104,8 @@ export class HatGraphBranch extends LitElement {
</div> </div>
${!this.short ${!this.short
? svg` ? html`
<svg <svg id="bottom" width=${this._totalWidth}>
id="bottom"
width="${this._totalWidth}"
>
${this._branches.map((branch) => { ${this._branches.map((branch) => {
if (branch.end) return ""; if (branch.end) return "";
return svg` return svg`
@ -128,7 +122,7 @@ export class HatGraphBranch extends LitElement {
})} })}
</svg> </svg>
` `
: ""} : nothing}
`; `;
} }

View File

@ -1,6 +1,8 @@
import { memoize } from "@fullcalendar/core/internal";
import { setHours, setMinutes } from "date-fns"; import { setHours, setMinutes } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket"; import type { HassConfig } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import checkValidDate from "../common/datetime/check_valid_date";
import { import {
formatDateTime, formatDateTime,
formatDateTimeNumeric, formatDateTimeNumeric,
@ -11,7 +13,6 @@ import type { HomeAssistant } from "../types";
import { fileDownload } from "../util/file_download"; import { fileDownload } from "../util/file_download";
import { domainToName } from "./integration"; import { domainToName } from "./integration";
import type { FrontendLocaleData } from "./translation"; import type { FrontendLocaleData } from "./translation";
import checkValidDate from "../common/datetime/check_valid_date";
export const enum BackupScheduleRecurrence { export const enum BackupScheduleRecurrence {
NEVER = "never", NEVER = "never",
@ -104,6 +105,9 @@ export interface BackupContent {
name: string; name: string;
agents: Record<string, BackupContentAgent>; agents: Record<string, BackupContentAgent>;
failed_agent_ids?: string[]; failed_agent_ids?: string[];
extra_metadata?: {
"supervisor.addon_update"?: string;
};
with_automatic_settings: boolean; with_automatic_settings: boolean;
} }
@ -319,6 +323,29 @@ export const computeBackupAgentName = (
export const computeBackupSize = (backup: BackupContent) => export const computeBackupSize = (backup: BackupContent) =>
Math.max(...Object.values(backup.agents).map((agent) => agent.size)); Math.max(...Object.values(backup.agents).map((agent) => agent.size));
export type BackupType = "automatic" | "manual" | "addon_update";
const BACKUP_TYPE_ORDER: BackupType[] = ["automatic", "manual", "addon_update"];
export const getBackupTypes = memoize((isHassio: boolean) =>
isHassio
? BACKUP_TYPE_ORDER
: BACKUP_TYPE_ORDER.filter((type) => type !== "addon_update")
);
export const computeBackupType = (
backup: BackupContent,
isHassio: boolean
): BackupType => {
if (backup.with_automatic_settings) {
return "automatic";
}
if (isHassio && backup.extra_metadata?.["supervisor.addon_update"] != null) {
return "addon_update";
}
return "manual";
};
export const compareAgents = (a: string, b: string) => { export const compareAgents = (a: string, b: string) => {
const isLocalA = isLocalAgent(a); const isLocalA = isLocalAgent(a);
const isLocalB = isLocalAgent(b); const isLocalB = isLocalAgent(b);

View File

@ -181,3 +181,6 @@ export const updateCloudGoogleEntityConfig = (
export const cloudSyncGoogleAssistant = (hass: HomeAssistant) => export const cloudSyncGoogleAssistant = (hass: HomeAssistant) =>
hass.callApi("POST", "cloud/google_actions/sync"); hass.callApi("POST", "cloud/google_actions/sync");
export const fetchSupportPackage = (hass: HomeAssistant) =>
hass.callApi<string>("GET", "cloud/support_package");

View File

@ -65,7 +65,8 @@ class StepFlowCreateEntry extends LitElement {
if ( if (
devices.length !== 1 || devices.length !== 1 ||
devices[0].primary_config_entry !== this.step.result?.entry_id devices[0].primary_config_entry !== this.step.result?.entry_id ||
this.step.result.domain === "voip"
) { ) {
return; return;
} }

View File

@ -448,6 +448,10 @@ class MoreInfoUpdate extends LitElement {
box-sizing: border-box; box-sizing: border-box;
margin-bottom: -16px; margin-bottom: -16px;
margin-top: -4px; margin-top: -4px;
--md-sys-color-surface: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
} }
ha-md-list-item { ha-md-list-item {

View File

@ -47,6 +47,8 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
@state() private _assistConfiguration?: AssistSatelliteConfiguration; @state() private _assistConfiguration?: AssistSatelliteConfiguration;
@state() private _error?: string;
private _previousSteps: STEP[] = []; private _previousSteps: STEP[] = [];
private _nextStep?: STEP; private _nextStep?: STEP;
@ -165,79 +167,86 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
"update" "update"
)} )}
></ha-voice-assistant-setup-step-update>` ></ha-voice-assistant-setup-step-update>`
: assistEntityState?.state === UNAVAILABLE : this._error
? this.hass.localize( ? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
"ui.panel.config.voice_assistants.satellite_wizard.not_available" : assistEntityState?.state === UNAVAILABLE
) ? html`<ha-alert alert-type="error"
: this._step === STEP.CHECK >${this.hass.localize(
? html`<ha-voice-assistant-setup-step-check "ui.panel.config.voice_assistants.satellite_wizard.not_available"
.hass=${this.hass} )}</ha-alert
.assistEntityId=${assistSatelliteEntityId} >`
></ha-voice-assistant-setup-step-check>` : this._step === STEP.CHECK
: this._step === STEP.WAKEWORD ? html`<ha-voice-assistant-setup-step-check
? html`<ha-voice-assistant-setup-step-wake-word
.hass=${this.hass} .hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId} .assistEntityId=${assistSatelliteEntityId}
.deviceEntities=${this._deviceEntities( ></ha-voice-assistant-setup-step-check>`
this._params.deviceId, : this._step === STEP.WAKEWORD
this.hass.entities ? html`<ha-voice-assistant-setup-step-wake-word
)} .hass=${this.hass}
></ha-voice-assistant-setup-step-wake-word>` .assistConfiguration=${this._assistConfiguration}
: this._step === STEP.CHANGE_WAKEWORD .assistEntityId=${assistSatelliteEntityId}
? html` .deviceEntities=${this._deviceEntities(
<ha-voice-assistant-setup-step-change-wake-word this._params.deviceId,
.hass=${this.hass} this.hass.entities
.assistConfiguration=${this._assistConfiguration} )}
.assistEntityId=${assistSatelliteEntityId} ></ha-voice-assistant-setup-step-wake-word>`
></ha-voice-assistant-setup-step-change-wake-word> : this._step === STEP.CHANGE_WAKEWORD
`
: this._step === STEP.AREA
? html` ? html`
<ha-voice-assistant-setup-step-area <ha-voice-assistant-setup-step-change-wake-word
.hass=${this.hass}
.deviceId=${this._params.deviceId}
></ha-voice-assistant-setup-step-area>
`
: this._step === STEP.PIPELINE
? html`<ha-voice-assistant-setup-step-pipeline
.hass=${this.hass} .hass=${this.hass}
.assistConfiguration=${this._assistConfiguration} .assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId} .assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-pipeline>` ></ha-voice-assistant-setup-step-change-wake-word>
: this._step === STEP.CLOUD `
? html`<ha-voice-assistant-setup-step-cloud : this._step === STEP.AREA
? html`
<ha-voice-assistant-setup-step-area
.hass=${this.hass} .hass=${this.hass}
></ha-voice-assistant-setup-step-cloud>` .deviceId=${this._params.deviceId}
: this._step === STEP.LOCAL ></ha-voice-assistant-setup-step-area>
? html`<ha-voice-assistant-setup-step-local `
: this._step === STEP.PIPELINE
? html`<ha-voice-assistant-setup-step-pipeline
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-pipeline>`
: this._step === STEP.CLOUD
? html`<ha-voice-assistant-setup-step-cloud
.hass=${this.hass} .hass=${this.hass}
.assistConfiguration=${this ></ha-voice-assistant-setup-step-cloud>`
._assistConfiguration} : this._step === STEP.LOCAL
></ha-voice-assistant-setup-step-local>` ? html`<ha-voice-assistant-setup-step-local
: this._step === STEP.SUCCESS
? html`<ha-voice-assistant-setup-step-success
.hass=${this.hass} .hass=${this.hass}
.assistConfiguration=${this .assistConfiguration=${this
._assistConfiguration} ._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId} ></ha-voice-assistant-setup-step-local>`
></ha-voice-assistant-setup-step-success>` : this._step === STEP.SUCCESS
: nothing} ? html`<ha-voice-assistant-setup-step-success
.hass=${this.hass}
.assistConfiguration=${this
._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-success>`
: nothing}
</div> </div>
</ha-dialog> </ha-dialog>
`; `;
} }
private async _fetchAssistConfiguration() { private async _fetchAssistConfiguration() {
this._assistConfiguration = await fetchAssistSatelliteConfiguration( try {
this.hass, this._assistConfiguration = await fetchAssistSatelliteConfiguration(
this._findDomainEntityId( this.hass,
this._params!.deviceId, this._findDomainEntityId(
this.hass.entities, this._params!.deviceId,
"assist_satellite" this.hass.entities,
)! "assist_satellite"
); )!
return this._assistConfiguration; );
} catch (err: any) {
this._error = err.message;
}
} }
private _goToPreviousStep() { private _goToPreviousStep() {
@ -293,6 +302,10 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
.skip-btn { .skip-btn {
margin-top: 6px; margin-top: 6px;
} }
ha-alert {
margin: 24px;
display: block;
}
`, `,
]; ];
} }

View File

@ -85,7 +85,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
<div class="rows"> <div class="rows">
${this.assistConfiguration && ${this.assistConfiguration &&
this.assistConfiguration.available_wake_words.length > 1 this.assistConfiguration.available_wake_words.length > 1
? html` <div class="row"> ? html`<div class="row">
<ha-select <ha-select
.label=${"Wake word"} .label=${"Wake word"}
@closed=${stopPropagation} @closed=${stopPropagation}

View File

@ -44,6 +44,15 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
protected override willUpdate(changedProperties: PropertyValues) { protected override willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties); super.willUpdate(changedProperties);
if (changedProperties.has("assistConfiguration")) {
if (
this.assistConfiguration &&
!this.assistConfiguration.available_wake_words.length
) {
this._nextStep();
}
}
if (changedProperties.has("assistEntityId")) { if (changedProperties.has("assistEntityId")) {
this._detected = false; this._detected = false;
this._muteSwitchEntity = this.deviceEntities?.find( this._muteSwitchEntity = this.deviceEntities?.find(
@ -135,13 +144,16 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
>` >`
: nothing} : nothing}
</div> </div>
<div class="footer centered"> ${this.assistConfiguration &&
<ha-button @click=${this._changeWakeWord} this.assistConfiguration.available_wake_words.length > 1
>${this.hass.localize( ? html`<div class="footer centered">
"ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word" <ha-button @click=${this._changeWakeWord}
)}</ha-button >${this.hass.localize(
> "ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word"
</div>`; )}</ha-button
>
</div>`
: nothing}`;
} }
private async _listenWakeWord() { private async _listenWakeWord() {

View File

@ -106,6 +106,7 @@ export class HaConfigApplicationCredentials extends LitElement {
}, },
actions: { actions: {
title: "", title: "",
label: localize("ui.panel.config.generic.headers.actions"),
type: "overflow-menu", type: "overflow-menu",
showNarrow: true, showNarrow: true,
hideable: false, hideable: false,

View File

@ -1,4 +1,4 @@
import { mdiCog, mdiHarddisk, mdiNas } from "@mdi/js"; import { mdiCog, mdiDelete, mdiHarddisk, mdiNas } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
@ -41,13 +41,6 @@ class HaBackupConfigAgents extends LitElement {
@state() private value?: string[]; @state() private value?: string[];
private _availableAgents = memoizeOne(
(agents: BackupAgent[], cloudStatus: CloudStatus) =>
agents.filter(
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
)
);
private get _value() { private get _value() {
return this.value ?? DEFAULT_AGENTS; return this.value ?? DEFAULT_AGENTS;
} }
@ -86,19 +79,84 @@ class HaBackupConfigAgents extends LitElement {
return ""; return "";
} }
protected render() { private _availableAgents = memoizeOne(
const agents = this._availableAgents(this.agents, this.cloudStatus); (agents: BackupAgent[], cloudStatus: CloudStatus) =>
agents.filter(
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
)
);
private _unavailableAgents = memoizeOne(
(
agents: BackupAgent[],
cloudStatus: CloudStatus,
selectedAgentIds: string[]
) => {
const availableAgentIds = this._availableAgents(agents, cloudStatus).map(
(agent) => agent.agent_id
);
return selectedAgentIds
.filter((agent) => !availableAgentIds.includes(agent))
.map<BackupAgent>((id) => ({
agent_id: id,
name: id.split(".")[1] || id, // Use the id as name as it is not available in the list
}));
}
);
private _renderAgentIcon(agentId: string) {
if (isLocalAgent(agentId)) {
return html`
<ha-svg-icon .path=${mdiHarddisk} slot="start"></ha-svg-icon>
`;
}
if (isNetworkMountAgent(agentId)) {
return html`<ha-svg-icon .path=${mdiNas} slot="start"></ha-svg-icon>`;
}
const domain = computeDomain(agentId);
return html` return html`
${agents.length > 0 <img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
`;
}
protected render() {
const availableAgents = this._availableAgents(
this.agents,
this.cloudStatus
);
const unavailableAgents = this._unavailableAgents(
this.agents,
this.cloudStatus,
this._value
);
const allAgents = [...availableAgents, ...unavailableAgents];
return html`
${allAgents.length > 0
? html` ? html`
<ha-md-list> <ha-md-list>
${agents.map((agent) => { ${availableAgents.map((agent) => {
const agentId = agent.agent_id; const agentId = agent.agent_id;
const domain = computeDomain(agentId);
const name = computeBackupAgentName( const name = computeBackupAgentName(
this.hass.localize, this.hass.localize,
agentId, agentId,
this.agents allAgents
); );
const description = this._description(agentId); const description = this._description(agentId);
const noCloudSubscription = const noCloudSubscription =
@ -108,32 +166,7 @@ class HaBackupConfigAgents extends LitElement {
return html` return html`
<ha-md-list-item> <ha-md-list-item>
${isLocalAgent(agentId) ${this._renderAgentIcon(agentId)}
? html`
<ha-svg-icon .path=${mdiHarddisk} slot="start">
</ha-svg-icon>
`
: isNetworkMountAgent(agentId)
? html`
<ha-svg-icon
.path=${mdiNas}
slot="start"
></ha-svg-icon>
`
: html`
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
`}
<div slot="headline" class="name">${name}</div> <div slot="headline" class="name">${name}</div>
${description ${description
? html`<div slot="supporting-text">${description}</div>` ? html`<div slot="supporting-text">${description}</div>`
@ -151,14 +184,44 @@ class HaBackupConfigAgents extends LitElement {
<ha-switch <ha-switch
slot="end" slot="end"
id=${agentId} id=${agentId}
.checked=${!noCloudSubscription && .checked=${this._value.includes(agentId)}
this._value.includes(agentId)} .disabled=${noCloudSubscription &&
.disabled=${noCloudSubscription} !this._value.includes(agentId)}
@change=${this._agentToggled} @change=${this._agentToggled}
></ha-switch> ></ha-switch>
</ha-md-list-item> </ha-md-list-item>
`; `;
})} })}
${unavailableAgents.length > 0 && this.showSettings
? html`
<p class="heading">
${this.hass.localize(
"ui.panel.config.backup.agents.unavailable_agents"
)}
</p>
${unavailableAgents.map((agent) => {
const agentId = agent.agent_id;
const name = computeBackupAgentName(
this.hass.localize,
agentId,
allAgents
);
return html`
<ha-md-list-item>
${this._renderAgentIcon(agentId)}
<div slot="headline" class="name">${name}</div>
<ha-icon-button
id=${agentId}
slot="end"
path=${mdiDelete}
@click=${this._deleteAgent}
></ha-icon-button>
</ha-md-list-item>
`;
})}
`
: nothing}
</ha-md-list> </ha-md-list>
` `
: html` : html`
@ -174,6 +237,13 @@ class HaBackupConfigAgents extends LitElement {
navigate(`/config/backup/location/${agentId}`); navigate(`/config/backup/location/${agentId}`);
} }
private _deleteAgent(ev): void {
ev.stopPropagation();
const agentId = ev.currentTarget.id;
this.value = this._value.filter((agent) => agent !== agentId);
fireEvent(this, "value-changed", { value: this.value });
}
private _agentToggled(ev) { private _agentToggled(ev) {
ev.stopPropagation(); ev.stopPropagation();
const value = ev.currentTarget.checked; const value = ev.currentTarget.checked;
@ -185,19 +255,8 @@ class HaBackupConfigAgents extends LitElement {
this.value = this._value.filter((agent) => agent !== agentId); this.value = this._value.filter((agent) => agent !== agentId);
} }
const availableAgents = this._availableAgents(
this.agents,
this.cloudStatus
);
// Ensure we don't have duplicates, agents exist in the list and cloud is logged in // Ensure we don't have duplicates, agents exist in the list and cloud is logged in
this.value = [...new Set(this.value)] this.value = [...new Set(this.value)];
.filter((id) => availableAgents.some((agent) => agent.agent_id === id))
.filter(
(id) =>
id !== CLOUD_AGENT ||
(this.cloudStatus.logged_in && this.cloudStatus.active_subscription)
);
fireEvent(this, "value-changed", { value: this.value }); fireEvent(this, "value-changed", { value: this.value });
} }

View File

@ -378,8 +378,9 @@ class HaBackupConfigData extends LitElement {
} }
@media all and (max-width: 450px) { @media all and (max-width: 450px) {
ha-md-select { ha-md-select {
min-width: 160px; min-width: 140px;
width: 160px; width: 140px;
--md-filled-field-content-space: 0;
} }
} }
`; `;

View File

@ -403,11 +403,11 @@ class HaBackupConfigSchedule extends LitElement {
backup_create: html`<a backup_create: html`<a
href=${documentationUrl( href=${documentationUrl(
this.hass, this.hass,
"/integrations/backup#example-backing-up-every-night-at-300-am" "/integrations/backup/#action-backupcreate_automatic"
)} )}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
>backup.create</a >backup.create_automatic</a
>`, >`,
})}</ha-tip })}</ha-tip
> >
@ -537,14 +537,22 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-list-item { ha-md-list-item {
--md-item-overflow: visible; --md-item-overflow: visible;
} }
ha-md-select, ha-md-select {
ha-time-input {
min-width: 210px; min-width: 210px;
} }
ha-time-input {
min-width: 194px;
--time-input-flex: 1;
}
@media all and (max-width: 450px) { @media all and (max-width: 450px) {
ha-md-select, ha-md-select {
ha-time-input {
min-width: 160px; min-width: 160px;
width: 160px;
--md-filled-field-content-space: 0;
}
ha-time-input {
min-width: 145px;
width: 145px;
} }
} }
ha-md-textfield#value { ha-md-textfield#value {
@ -553,6 +561,16 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-select#type { ha-md-select#type {
min-width: 100px; min-width: 100px;
} }
@media all and (max-width: 450px) {
ha-md-textfield#value {
min-width: 60px;
margin: 0 -8px;
}
ha-md-select#type {
min-width: 120px;
width: 120px;
}
}
ha-expansion-panel { ha-expansion-panel {
--expansion-panel-summary-padding: 0 16px; --expansion-panel-summary-padding: 0 16px;
--expansion-panel-content-padding: 0 16px; --expansion-panel-content-padding: 0 16px;

View File

@ -1,16 +1,19 @@
import { mdiCalendarSync, mdiGestureTap } from "@mdi/js"; import { mdiCalendarSync, mdiGestureTap, mdiPuzzle } from "@mdi/js";
import type { CSSResultGroup } from "lit"; import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import "../../../../../components/ha-button"; import "../../../../../components/ha-button";
import "../../../../../components/ha-card"; import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next"; import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list"; import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item"; import "../../../../../components/ha-md-list-item";
import type { BackupContent, BackupType } from "../../../../../data/backup";
import { import {
computeBackupSize, computeBackupSize,
type BackupContent, computeBackupType,
getBackupTypes,
} from "../../../../../data/backup"; } from "../../../../../data/backup";
import { haStyle } from "../../../../../resources/styles"; import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types"; import type { HomeAssistant } from "../../../../../types";
@ -21,6 +24,12 @@ interface BackupStats {
size: number; size: number;
} }
const TYPE_ICONS: Record<BackupType, string> = {
automatic: mdiCalendarSync,
manual: mdiGestureTap,
addon_update: mdiPuzzle,
};
const computeBackupStats = (backups: BackupContent[]): BackupStats => const computeBackupStats = (backups: BackupContent[]): BackupStats =>
backups.reduce( backups.reduce(
(stats, backup) => { (stats, backup) => {
@ -37,23 +46,22 @@ class HaBackupOverviewBackups extends LitElement {
@property({ attribute: false }) public backups: BackupContent[] = []; @property({ attribute: false }) public backups: BackupContent[] = [];
private _automaticStats = memoizeOne((backups: BackupContent[]) => { private _stats = memoizeOne(
const automaticBackups = backups.filter( (
(backup) => backup.with_automatic_settings backups: BackupContent[],
); isHassio: boolean
return computeBackupStats(automaticBackups); ): [BackupType, BackupStats][] =>
}); getBackupTypes(isHassio).map((type) => {
const backupsOfType = backups.filter(
private _manualStats = memoizeOne((backups: BackupContent[]) => { (backup) => computeBackupType(backup, isHassio) === type
const manualBackups = backups.filter( );
(backup) => !backup.with_automatic_settings return [type, computeBackupStats(backupsOfType)] as const;
); })
return computeBackupStats(manualBackups); );
});
render() { render() {
const automaticStats = this._automaticStats(this.backups); const isHassio = isComponentLoaded(this.hass, "hassio");
const manualStats = this._manualStats(this.backups); const stats = this._stats(this.backups, isHassio);
return html` return html`
<ha-card class="my-backups"> <ha-card class="my-backups">
@ -62,44 +70,32 @@ class HaBackupOverviewBackups extends LitElement {
</div> </div>
<div class="card-content"> <div class="card-content">
<ha-md-list> <ha-md-list>
<ha-md-list-item ${stats.map(
type="link" ([type, { count, size }]) => html`
href="/config/backup/backups?type=automatic" <ha-md-list-item
> type="link"
<ha-svg-icon slot="start" .path=${mdiCalendarSync}></ha-svg-icon> href="/config/backup/backups?type=${type}"
<div slot="headline"> >
${this.hass.localize( <ha-svg-icon
"ui.panel.config.backup.overview.backups.automatic", slot="start"
{ count: automaticStats.count } .path=${TYPE_ICONS[type]}
)} ></ha-svg-icon>
</div> <div slot="headline">
<div slot="supporting-text"> ${this.hass.localize(
${this.hass.localize( `ui.panel.config.backup.overview.backups.${type}`,
"ui.panel.config.backup.overview.backups.total_size", { count }
{ size: bytesToString(automaticStats.size, 1) } )}
)} </div>
</div> <div slot="supporting-text">
<ha-icon-next slot="end"></ha-icon-next> ${this.hass.localize(
</ha-md-list-item> "ui.panel.config.backup.overview.backups.total_size",
<ha-md-list-item { size: bytesToString(size) }
type="link" )}
href="/config/backup/backups?type=manual" </div>
> <ha-icon-next slot="end"></ha-icon-next>
<ha-svg-icon slot="start" .path=${mdiGestureTap}></ha-svg-icon> </ha-md-list-item>
<div slot="headline"> `
${this.hass.localize( )}
"ui.panel.config.backup.overview.backups.manual",
{ count: manualStats.count }
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.total_size",
{ size: bytesToString(manualStats.size, 1) }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list> </ha-md-list>
</div> </div>
<div class="card-actions"> <div class="card-actions">

View File

@ -0,0 +1,225 @@
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-password-field";
import "../../../../components/ha-alert";
import {
canDecryptBackupOnDownload,
getPreferredAgentForDownload,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { downloadBackupFile } from "../helper/download_backup";
import type { DownloadDecryptedBackupDialogParams } from "./show-dialog-download-decrypted-backup";
@customElement("ha-dialog-download-decrypted-backup")
class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@state() private _params?: DownloadDecryptedBackupDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _encryptionKey = "";
@state() private _error = "";
public showDialog(params: DownloadDecryptedBackupDialogParams): void {
this._opened = true;
this._params = params;
}
public closeDialog() {
this._dialog?.close();
return true;
}
private _dialogClosed() {
if (this._opened) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._opened = false;
this._params = undefined;
this._encryptionKey = "";
this._error = "";
}
protected render() {
if (!this._opened || !this._params) {
return nothing;
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed} disable-cancel-action>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title">
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.title"
)}
</span>
</ha-dialog-header>
<div slot="content">
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.description"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_backup_encrypted",
{
download_it_encrypted: html`<button
class="link"
@click=${this._downloadEncrypted}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_it_encrypted"
)}
</button>`,
}
)}
</p>
<ha-password-field
.label=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.encryption_key"
)}
@input=${this._keyChanged}
></ha-password-field>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
</div>
<div slot="actions">
<ha-button @click=${this._cancel}>
${this.hass.localize("ui.dialogs.generic.cancel")}
</ha-button>
<ha-button @click=${this._submit}>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download"
)}
</ha-button>
</div>
</ha-md-dialog>
`;
}
private _cancel() {
this.closeDialog();
}
private async _submit() {
if (this._encryptionKey === "") {
return;
}
try {
await canDecryptBackupOnDownload(
this.hass,
this._params!.backup.backup_id,
this._agentId,
this._encryptionKey
);
downloadBackupFile(
this.hass,
this._params!.backup.backup_id,
this._agentId,
this._encryptionKey
);
this.closeDialog();
} catch (err: any) {
if (err?.code === "password_incorrect") {
this._error = this.hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_encryption_key"
);
} else if (err?.code === "decrypt_not_supported") {
this._error = this.hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_not_supported"
);
} else {
alert(err.message);
}
}
}
private _keyChanged(ev) {
this._encryptionKey = ev.currentTarget.value;
this._error = "";
}
private get _agentId() {
if (this._params?.agentId) {
return this._params.agentId;
}
return getPreferredAgentForDownload(
Object.keys(this._params!.backup.agents)
);
}
private async _downloadEncrypted() {
downloadBackupFile(
this.hass,
this._params!.backup.backup_id,
this._agentId
);
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-md-dialog {
--dialog-content-padding: 8px 24px;
max-width: 500px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
max-width: none;
}
div[slot="content"] {
margin-top: 0;
}
}
button.link {
background: none;
border: none;
padding: 0;
font-size: 14px;
color: var(--primary-color);
text-decoration: underline;
cursor: pointer;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-download-decrypted-backup": DialogDownloadDecryptedBackup;
}
}

View File

@ -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,
});
};

View File

@ -11,6 +11,7 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { relativeTime } from "../../../common/datetime/relative_time"; import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage"; import { storage } from "../../../common/decorators/storage";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event"; import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
@ -42,9 +43,11 @@ import {
compareAgents, compareAgents,
computeBackupAgentName, computeBackupAgentName,
computeBackupSize, computeBackupSize,
computeBackupType,
deleteBackup, deleteBackup,
generateBackup, generateBackup,
generateBackupWithAutomaticSettings, generateBackupWithAutomaticSettings,
getBackupTypes,
isLocalAgent, isLocalAgent,
isNetworkMountAgent, isNetworkMountAgent,
} from "../../../data/backup"; } from "../../../data/backup";
@ -74,10 +77,6 @@ interface BackupRow extends DataTableRowData, BackupContent {
agent_ids: string[]; agent_ids: string[];
} }
type BackupType = "automatic" | "manual";
const TYPE_ORDER: BackupType[] = ["automatic", "manual"];
@customElement("ha-config-backup-backups") @customElement("ha-config-backup-backups")
class HaConfigBackupBackups extends SubscribeMixin(LitElement) { class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -141,7 +140,10 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}; };
private _columns = memoizeOne( private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer<BackupRow> => ({ (
localize: LocalizeFunc,
maxDisplayedAgents: number
): DataTableColumnContainer<BackupRow> => ({
name: { name: {
title: localize("ui.panel.config.backup.name"), title: localize("ui.panel.config.backup.name"),
main: true, main: true,
@ -172,54 +174,75 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
locations: { locations: {
title: localize("ui.panel.config.backup.locations"), title: localize("ui.panel.config.backup.locations"),
showNarrow: true, showNarrow: true,
minWidth: "60px", // 24 icon size, 4 gap, 16 left and right padding
template: (backup) => html` minWidth: `${maxDisplayedAgents * 24 + (maxDisplayedAgents - 1) * 4 + 32}px`,
<div style="display: flex; gap: 4px;"> template: (backup) => {
${(backup.agent_ids || []).map((agentId) => { const agentIds = backup.agent_ids;
const name = computeBackupAgentName( const displayedAgentIds =
this.hass.localize, agentIds.length > maxDisplayedAgents
agentId, ? [...agentIds].splice(0, maxDisplayedAgents - 1)
this.agents : agentIds;
); const agentsMore = Math.max(
if (isLocalAgent(agentId)) { agentIds.length - displayedAgentIds.length,
0
);
return html`
<div style="display: flex; gap: 4px;">
${displayedAgentIds.map((agentId) => {
const name = computeBackupAgentName(
this.hass.localize,
agentId,
this.agents
);
if (isLocalAgent(agentId)) {
return html`
<ha-svg-icon
.path=${mdiHarddisk}
title=${name}
style="flex-shrink: 0;"
></ha-svg-icon>
`;
}
if (isNetworkMountAgent(agentId)) {
return html`
<ha-svg-icon
.path=${mdiNas}
title=${name}
style="flex-shrink: 0;"
></ha-svg-icon>
`;
}
const domain = computeDomain(agentId);
return html` return html`
<ha-svg-icon <img
.path=${mdiHarddisk}
title=${name} title=${name}
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
height="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${name}
slot="graphic"
style="flex-shrink: 0;" style="flex-shrink: 0;"
></ha-svg-icon> />
`; `;
} })}
if (isNetworkMountAgent(agentId)) { ${agentsMore
return html` ? html`
<ha-svg-icon <span
.path=${mdiNas} style="display: flex; align-items: center; font-size: 14px;"
title=${name} >
style="flex-shrink: 0;" +${agentsMore}
></ha-svg-icon> </span>
`; `
} : nothing}
const domain = computeDomain(agentId); </div>
return html` `;
<img },
title=${name}
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
height="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${name}
slot="graphic"
style="flex-shrink: 0;"
/>
`;
})}
</div>
`,
}, },
actions: { actions: {
title: "", title: "",
@ -253,9 +276,13 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
); );
private _groupOrder = memoizeOne( private _groupOrder = memoizeOne(
(activeGrouping: string | undefined, localize: LocalizeFunc) => (
activeGrouping: string | undefined,
localize: LocalizeFunc,
isHassio: boolean
) =>
activeGrouping === "formatted_type" activeGrouping === "formatted_type"
? TYPE_ORDER.map((type) => ? getBackupTypes(isHassio).map((type) =>
localize(`ui.panel.config.backup.type.${type}`) localize(`ui.panel.config.backup.type.${type}`)
) )
: undefined : undefined
@ -279,33 +306,48 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
( (
backups: BackupContent[], backups: BackupContent[],
filters: DataTableFiltersValues, filters: DataTableFiltersValues,
localize: LocalizeFunc localize: LocalizeFunc,
isHassio: boolean
): BackupRow[] => { ): BackupRow[] => {
const typeFilter = filters["ha-filter-states"] as string[] | undefined; const typeFilter = filters["ha-filter-states"] as string[] | undefined;
let filteredBackups = backups; let filteredBackups = backups;
if (typeFilter?.length) { if (typeFilter?.length) {
filteredBackups = filteredBackups.filter( filteredBackups = filteredBackups.filter((backup) => {
(backup) => const type = computeBackupType(backup, isHassio);
(backup.with_automatic_settings && return typeFilter.includes(type);
typeFilter.includes("automatic")) || });
(!backup.with_automatic_settings && typeFilter.includes("manual"))
);
} }
return filteredBackups.map((backup) => { return filteredBackups.map((backup) => {
const type = backup.with_automatic_settings ? "automatic" : "manual"; const type = computeBackupType(backup, isHassio);
const agentIds = Object.keys(backup.agents);
return { return {
...backup, ...backup,
size: computeBackupSize(backup), size: computeBackupSize(backup),
agent_ids: Object.keys(backup.agents).sort(compareAgents), agent_ids: agentIds.sort(compareAgents),
formatted_type: localize(`ui.panel.config.backup.type.${type}`), formatted_type: localize(`ui.panel.config.backup.type.${type}`),
}; };
}); });
} }
); );
private _maxAgents = memoizeOne((data: BackupRow[]): number =>
Math.max(...data.map((row) => row.agent_ids.length))
);
protected render(): TemplateResult { protected render(): TemplateResult {
const backupInProgress = const backupInProgress =
"state" in this.manager && this.manager.state === "in_progress"; "state" in this.manager && this.manager.state === "in_progress";
const isHassio = isComponentLoaded(this.hass, "hassio");
const data = this._data(
this.backups,
this._filters,
this.hass.localize,
isHassio
);
const maxDisplayedAgents = Math.min(
this._maxAgents(data),
this.narrow ? 3 : 5
);
return html` return html`
<hass-tabs-subpage-data-table <hass-tabs-subpage-data-table
@ -336,15 +378,16 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
.initialCollapsedGroups=${this._activeCollapsed} .initialCollapsedGroups=${this._activeCollapsed}
.groupOrder=${this._groupOrder( .groupOrder=${this._groupOrder(
this._activeGrouping, this._activeGrouping,
this.hass.localize this.hass.localize,
isHassio
)} )}
@grouping-changed=${this._handleGroupingChanged} @grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged} @collapsed-changed=${this._handleCollapseChanged}
@selection-changed=${this._handleSelectionChanged} @selection-changed=${this._handleSelectionChanged}
.route=${this.route} .route=${this.route}
@row-click=${this._showBackupDetails} @row-click=${this._showBackupDetails}
.columns=${this._columns(this.hass.localize)} .columns=${this._columns(this.hass.localize, maxDisplayedAgents)}
.data=${this._data(this.backups, this._filters, this.hass.localize)} .data=${data}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")} .noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
.searchLabel=${this.hass.localize( .searchLabel=${this.hass.localize(
"ui.panel.config.backup.picker.search" "ui.panel.config.backup.picker.search"
@ -400,7 +443,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
.hass=${this.hass} .hass=${this.hass}
.label=${this.hass.localize("ui.panel.config.backup.backup_type")} .label=${this.hass.localize("ui.panel.config.backup.backup_type")}
.value=${this._filters["ha-filter-states"]} .value=${this._filters["ha-filter-states"]}
.states=${this._states(this.hass.localize)} .states=${this._states(this.hass.localize, isHassio)}
@data-table-filter-changed=${this._filterChanged} @data-table-filter-changed=${this._filterChanged}
slot="filter-pane" slot="filter-pane"
expanded expanded
@ -425,8 +468,8 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
`; `;
} }
private _states = memoizeOne((localize: LocalizeFunc) => private _states = memoizeOne((localize: LocalizeFunc, isHassio: boolean) =>
TYPE_ORDER.map((type) => ({ getBackupTypes(isHassio).map((type) => ({
value: type, value: type,
label: localize(`ui.panel.config.backup.type.${type}`), label: localize(`ui.panel.config.backup.type.${type}`),
})) }))
@ -496,12 +539,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
} }
private async _downloadBackup(backup: BackupContent): Promise<void> { private async _downloadBackup(backup: BackupContent): Promise<void> {
downloadBackup( downloadBackup(this.hass, this, backup, this.config);
this.hass,
this,
backup,
this.config?.create_backup.password
);
} }
private async _deleteBackup(backup: BackupContent): Promise<void> { private async _deleteBackup(backup: BackupContent): Promise<void> {

View File

@ -31,6 +31,7 @@ import {
compareAgents, compareAgents,
computeBackupAgentName, computeBackupAgentName,
computeBackupSize, computeBackupSize,
computeBackupType,
deleteBackup, deleteBackup,
fetchBackupDetails, fetchBackupDetails,
isLocalAgent, isLocalAgent,
@ -46,6 +47,7 @@ import { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { downloadBackup } from "./helper/download_backup"; import { downloadBackup } from "./helper/download_backup";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
interface Agent extends BackupContentAgent { interface Agent extends BackupContentAgent {
id: string; id: string;
@ -110,6 +112,8 @@ class HaConfigBackupDetails extends LitElement {
return nothing; return nothing;
} }
const isHassio = isComponentLoaded(this.hass, "hassio");
return html` return html`
<hass-subpage <hass-subpage
back-path="/config/backup/backups" back-path="/config/backup/backups"
@ -161,6 +165,18 @@ class HaConfigBackupDetails extends LitElement {
</div> </div>
<div class="card-content"> <div class="card-content">
<ha-md-list class="summary"> <ha-md-list class="summary">
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.backup_type"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.type.${computeBackupType(this._backup, isHassio)}`
)}
</span>
</ha-md-list-item>
<ha-md-list-item> <ha-md-list-item>
<span slot="headline"> <span slot="headline">
${this.hass.localize( ${this.hass.localize(
@ -401,13 +417,7 @@ class HaConfigBackupDetails extends LitElement {
} }
private async _downloadBackup(agentId?: string): Promise<void> { private async _downloadBackup(agentId?: string): Promise<void> {
await downloadBackup( await downloadBackup(this.hass, this, this._backup!, this.config, agentId);
this.hass,
this,
this._backup!,
this.config?.create_backup.password,
agentId
);
} }
private async _deleteBackup(): Promise<void> { private async _deleteBackup(): Promise<void> {

View File

@ -221,8 +221,7 @@ class HaConfigBackupOverview extends LitElement {
gap: 24px; gap: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-bottom: 24px; margin-bottom: calc(env(safe-area-inset-bottom) + 72px);
margin-bottom: 72px;
} }
.card-actions { .card-actions {
display: flex; display: flex;

View File

@ -50,9 +50,11 @@ class HaConfigBackupSettings extends LitElement {
} }
} }
protected firstUpdated(_changedProperties: PropertyValues): void { public connectedCallback(): void {
super.firstUpdated(_changedProperties); super.connectedCallback();
this._scrollToSection(); this._scrollToSection();
// Update config the page is displayed (e.g. when coming back from a location detail page)
this._config = this.config;
} }
private async _scrollToSection() { private async _scrollToSection() {

View File

@ -119,6 +119,7 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
settings: { settings: {
tag: "ha-config-backup-settings", tag: "ha-config-backup-settings",
load: () => import("./ha-config-backup-settings"), load: () => import("./ha-config-backup-settings"),
cache: true,
}, },
location: { location: {
tag: "ha-config-backup-location", tag: "ha-config-backup-location",

View File

@ -1,20 +1,17 @@
import type { LitElement } from "lit"; import type { LitElement } from "lit";
import { getSignedPath } from "../../../../data/auth";
import type { BackupConfig, BackupContent } from "../../../../data/backup";
import { import {
canDecryptBackupOnDownload, canDecryptBackupOnDownload,
getBackupDownloadUrl, getBackupDownloadUrl,
getPreferredAgentForDownload, getPreferredAgentForDownload,
type BackupContent,
} from "../../../../data/backup"; } from "../../../../data/backup";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../lovelace/custom-card-helpers";
import { getSignedPath } from "../../../../data/auth";
import { fileDownload } from "../../../../util/file_download"; import { fileDownload } from "../../../../util/file_download";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
import { showDownloadDecryptedBackupDialog } from "../dialogs/show-dialog-download-decrypted-backup";
const triggerDownload = async ( export const downloadBackupFile = async (
hass: HomeAssistant, hass: HomeAssistant,
backupId: string, backupId: string,
preferedAgent: string, preferedAgent: string,
@ -27,120 +24,80 @@ const triggerDownload = async (
fileDownload(signedUrl.path); fileDownload(signedUrl.path);
}; };
const downloadEncryptedBackup = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
agentId?: string
) => {
if (
await showConfirmationDialog(element, {
title: "Encryption key incorrect",
text: hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_entered_encryption_key"
),
confirmText: "Download encrypted",
})
) {
const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
triggerDownload(hass, backup.backup_id, preferedAgent);
}
};
const requestEncryptionKey = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
agentId?: string
): Promise<void> => {
const encryptionKey = await showPromptDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_current_encryption_key"
),
inputLabel: hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
),
inputType: "password",
confirmText: hass.localize("ui.common.download"),
});
if (encryptionKey === null) {
return;
}
downloadBackup(hass, element, backup, encryptionKey, agentId, true);
};
export const downloadBackup = async ( export const downloadBackup = async (
hass: HomeAssistant, hass: HomeAssistant,
element: LitElement, element: LitElement,
backup: BackupContent, backup: BackupContent,
encryptionKey?: string | null, backupConfig?: BackupConfig,
agentId?: string, agentId?: string
userProvided = false
): Promise<void> => { ): Promise<void> => {
const agentIds = Object.keys(backup.agents); const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds); const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
const isProtected = backup.agents[preferedAgent]?.protected; const isProtected = backup.agents[preferedAgent]?.protected;
if (isProtected) { if (!isProtected) {
if (encryptionKey) { downloadBackupFile(hass, backup.backup_id, preferedAgent);
try { return;
await canDecryptBackupOnDownload(
hass,
backup.backup_id,
preferedAgent,
encryptionKey
);
} catch (err: any) {
if (err?.code === "password_incorrect") {
if (userProvided) {
downloadEncryptedBackup(hass, element, backup, agentId);
} else {
requestEncryptionKey(hass, element, backup, agentId);
}
return;
}
if (err?.code === "decrypt_not_supported") {
showAlertDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_unsupported_title"
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_unsupported"
),
confirm() {
triggerDownload(hass, backup.backup_id, preferedAgent);
},
});
encryptionKey = undefined;
return;
}
showAlertDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_title",
{
error: err.message,
}
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_description",
{
error: err.message,
}
),
});
return;
}
} else {
requestEncryptionKey(hass, element, backup, agentId);
return;
}
} }
await triggerDownload(hass, backup.backup_id, preferedAgent, encryptionKey); const encryptionKey = backupConfig?.create_backup?.password;
if (!encryptionKey) {
showDownloadDecryptedBackupDialog(element, {
backup,
agentId: preferedAgent,
});
return;
}
try {
// Check if we can decrypt it
await canDecryptBackupOnDownload(
hass,
backup.backup_id,
preferedAgent,
encryptionKey
);
downloadBackupFile(hass, backup.backup_id, preferedAgent, encryptionKey);
} catch (err: any) {
// If encryption key is incorrect, ask for encryption key
if (err?.code === "password_incorrect") {
showDownloadDecryptedBackupDialog(element, {
backup,
agentId: preferedAgent,
});
return;
}
// If decryption is not supported, ask for confirmation and download it encrypted
if (err?.code === "decrypt_not_supported") {
showAlertDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_unsupported_title"
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_unsupported"
),
confirm() {
downloadBackupFile(hass, backup.backup_id, preferedAgent);
},
});
return;
}
// Else, show generic error
showAlertDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_title",
{
error: err.message,
}
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_description",
{
error: err.message,
}
),
});
}
}; };

View File

@ -1,15 +1,15 @@
import "@material/mwc-button"; import "@material/mwc-button";
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js";
import { formatDateTime } from "../../../../common/datetime/format_date_time"; import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { debounce } from "../../../../common/util/debounce"; import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert"; import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-tip";
import "../../../../components/ha-list-item";
import "../../../../components/ha-button-menu"; import "../../../../components/ha-button-menu";
import "../../../../components/ha-card";
import "../../../../components/ha-list-item";
import "../../../../components/ha-tip";
import type { import type {
CloudStatusLoggedIn, CloudStatusLoggedIn,
SubscriptionInfo, SubscriptionInfo,
@ -32,6 +32,7 @@ import "./cloud-ice-servers-pref";
import "./cloud-remote-pref"; import "./cloud-remote-pref";
import "./cloud-tts-pref"; import "./cloud-tts-pref";
import "./cloud-webhooks"; import "./cloud-webhooks";
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
@customElement("cloud-account") @customElement("cloud-account")
export class CloudAccount extends SubscribeMixin(LitElement) { export class CloudAccount extends SubscribeMixin(LitElement) {
@ -52,7 +53,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
.narrow=${this.narrow} .narrow=${this.narrow}
header="Home Assistant Cloud" header="Home Assistant Cloud"
> >
<ha-button-menu slot="toolbar-icon" @action=${this._deleteCloudData}> <ha-button-menu slot="toolbar-icon" @action=${this._handleMenuAction}>
<ha-icon-button <ha-icon-button
slot="trigger" slot="trigger"
.label=${this.hass.localize("ui.common.menu")} .label=${this.hass.localize("ui.common.menu")}
@ -65,6 +66,12 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
)} )}
<ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon> <ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon>
</ha-list-item> </ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
<ha-svg-icon slot="graphic" .path=${mdiDownload}></ha-svg-icon>
</ha-list-item>
</ha-button-menu> </ha-button-menu>
<div class="content"> <div class="content">
<ha-config-section .isWide=${this.isWide}> <ha-config-section .isWide=${this.isWide}>
@ -286,6 +293,16 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
fireEvent(this, "ha-refresh-cloud-status"); fireEvent(this, "ha-refresh-cloud-status");
} }
private _handleMenuAction(ev) {
switch (ev.detail.index) {
case 0:
this._deleteCloudData();
break;
case 1:
this._downloadSupportPackage();
}
}
private async _deleteCloudData() { private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, { const confirm = await showConfirmationDialog(this, {
title: this.hass.localize( title: this.hass.localize(
@ -316,6 +333,10 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
} }
} }
private async _downloadSupportPackage() {
showSupportPackageDialog(this);
}
static get styles() { static get styles() {
return [ return [
haStyle, haStyle,

View File

@ -0,0 +1,206 @@
import "@material/mwc-button";
import "@material/mwc-list/mwc-list-item";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-circular-progress";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-markdown-element";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-select";
import "../../../../components/ha-textarea";
import { fetchSupportPackage } from "../../../../data/cloud";
import type { HomeAssistant } from "../../../../types";
import { fileDownload } from "../../../../util/file_download";
@customElement("dialog-cloud-support-package")
export class DialogSupportPackage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _supportPackage?: string;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public showDialog() {
this._open = true;
this._loadSupportPackage();
}
private _dialogClosed(): void {
this._open = false;
this._supportPackage = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialog() {
this._dialog?.close();
return true;
}
protected render() {
if (!this._open) {
return nothing;
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span slot="title">Download support package</span>
</ha-dialog-header>
<div slot="content">
${this._supportPackage
? html`<ha-markdown-element
.content=${this._supportPackage}
breaks
></ha-markdown-element>`
: html`
<div class="progress-container">
<ha-circular-progress indeterminate></ha-circular-progress>
Generating preview...
</div>
`}
</div>
<div class="footer" slot="actions">
<ha-alert>
This file may contain personal data about your home. Avoid sharing
them with unverified or untrusted parties.
</ha-alert>
<hr />
<div class="actions">
<ha-button @click=${this.closeDialog}>Close</ha-button>
<ha-button @click=${this._download}>Download</ha-button>
</div>
</div>
</ha-md-dialog>
`;
}
private async _loadSupportPackage() {
this._supportPackage = await fetchSupportPackage(this.hass);
}
private async _download() {
fileDownload(
"data:text/plain;charset=utf-8," +
encodeURIComponent(this._supportPackage || ""),
"support-package.md"
);
}
static styles = css`
ha-md-dialog {
min-width: 90vw;
min-height: 90vh;
}
.progress-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(90vh - 260px);
width: 100%;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
min-width: 100vw;
min-height: 100vh;
}
.progress-container {
height: calc(100vh - 260px);
}
}
.footer {
flex-direction: column;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
hr {
border: none;
border-top: 1px solid var(--divider-color);
width: calc(100% + 48px);
margin-right: -24px;
margin-left: -24px;
}
table,
th,
td {
border: none;
}
table {
width: 100%;
display: table;
border-collapse: collapse;
border-spacing: 0;
}
table tr {
border-bottom: none;
}
table > tbody > tr:nth-child(odd) {
background-color: rgba(var(--rgb-primary-text-color), 0.04);
}
table > tbody > tr > td {
border-radius: 0;
}
table > tbody > tr {
-webkit-transition: background-color 0.25s ease;
transition: background-color 0.25s ease;
}
table > tbody > tr:hover {
background-color: rgba(var(--rgb-primary-text-color), 0.08);
}
tr {
border-bottom: 1px solid var(--divider-color);
}
td,
th {
padding: 15px 5px;
display: table-cell;
text-align: left;
vertical-align: middle;
border-radius: 2px;
}
details {
background-color: var(--secondary-background-color);
padding: 16px 24px;
margin: 8px 0;
border: 1px solid var(--divider-color);
border-radius: 16px;
}
summary {
font-weight: bold;
cursor: pointer;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-cloud-support-package": DialogSupportPackage;
}
}

View File

@ -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: {},
});
};

View File

@ -1,6 +1,6 @@
import "@material/mwc-button"; import "@material/mwc-button";
import "@material/mwc-list/mwc-list"; import "@material/mwc-list/mwc-list";
import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js"; import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
@ -27,6 +27,7 @@ import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles"; import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section"; import "../../ha-config-section";
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
@customElement("cloud-login") @customElement("cloud-login")
export class CloudLogin extends LitElement { export class CloudLogin extends LitElement {
@ -57,7 +58,7 @@ export class CloudLogin extends LitElement {
.narrow=${this.narrow} .narrow=${this.narrow}
header="Home Assistant Cloud" header="Home Assistant Cloud"
> >
<ha-button-menu slot="toolbar-icon" @action=${this._deleteCloudData}> <ha-button-menu slot="toolbar-icon" @action=${this._handleMenuAction}>
<ha-icon-button <ha-icon-button
slot="trigger" slot="trigger"
.label=${this.hass.localize("ui.common.menu")} .label=${this.hass.localize("ui.common.menu")}
@ -70,6 +71,12 @@ export class CloudLogin extends LitElement {
)} )}
<ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon> <ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon>
</ha-list-item> </ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
<ha-svg-icon slot="graphic" .path=${mdiDownload}></ha-svg-icon>
</ha-list-item>
</ha-button-menu> </ha-button-menu>
<div class="content"> <div class="content">
<ha-config-section .isWide=${this.isWide}> <ha-config-section .isWide=${this.isWide}>
@ -348,6 +355,16 @@ export class CloudLogin extends LitElement {
fireEvent(this, "flash-message-changed", { value: "" }); fireEvent(this, "flash-message-changed", { value: "" });
} }
private _handleMenuAction(ev) {
switch (ev.detail.index) {
case 0:
this._deleteCloudData();
break;
case 1:
this._downloadSupportPackage();
}
}
private async _deleteCloudData() { private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, { const confirm = await showConfirmationDialog(this, {
title: this.hass.localize( title: this.hass.localize(
@ -377,6 +394,10 @@ export class CloudLogin extends LitElement {
} }
} }
private async _downloadSupportPackage() {
showSupportPackageDialog(this);
}
static get styles() { static get styles() {
return [ return [
haStyle, haStyle,

View File

@ -1073,7 +1073,14 @@ export class HaConfigDevicePage extends LitElement {
(ent) => computeDomain(ent.entity_id) === "assist_satellite" (ent) => computeDomain(ent.entity_id) === "assist_satellite"
); );
const domains = this._integrations(
device,
this.entries,
this.manifests
).map((int) => int.domain);
if ( if (
!domains.includes("voip") &&
assistSatellite && assistSatellite &&
assistSatelliteSupportsSetupFlow( assistSatelliteSupportsSetupFlow(
this.hass.states[assistSatellite.entity_id] this.hass.states[assistSatellite.entity_id]
@ -1088,12 +1095,6 @@ export class HaConfigDevicePage extends LitElement {
}); });
} }
const domains = this._integrations(
device,
this.entries,
this.manifests
).map((int) => int.domain);
if (domains.includes("mqtt")) { if (domains.includes("mqtt")) {
const mqtt = await import( const mqtt = await import(
"./device-detail/integration-elements/mqtt/device-actions" "./device-detail/integration-elements/mqtt/device-actions"

View File

@ -39,7 +39,6 @@ import { hardwareBrandsUrl } from "../../../util/brands-url";
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available"; import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
import { extractApiErrorMessage } from "../../../data/hassio/common"; import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { ECOption } from "../../../resources/echarts"; import type { ECOption } from "../../../resources/echarts";
import { getTimeAxisLabelConfig } from "../../../components/chart/axis-label";
const DATASAMPLES = 60; const DATASAMPLES = 60;
@ -153,13 +152,6 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
this._chartOptions = { this._chartOptions = {
xAxis: { xAxis: {
type: "time", type: "time",
axisLabel: getTimeAxisLabelConfig(this.hass.locale, this.hass.config),
splitLine: {
show: true,
},
axisLine: {
show: false,
},
}, },
yAxis: { yAxis: {
type: "value", type: "value",

View File

@ -1,8 +1,9 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit"; import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import { storage } from "../../../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event"; import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize"; import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { import type {
@ -11,9 +12,6 @@ import type {
} from "../../../../../components/data-table/ha-data-table"; } from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-fab"; import "../../../../../components/ha-fab";
import "../../../../../components/ha-icon-button"; import "../../../../../components/ha-icon-button";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import type { import type {
BluetoothDeviceData, BluetoothDeviceData,
BluetoothScannersDetails, BluetoothScannersDetails,
@ -22,6 +20,10 @@ import {
subscribeBluetoothAdvertisements, subscribeBluetoothAdvertisements,
subscribeBluetoothScannersDetails, subscribeBluetoothScannersDetails,
} from "../../../../../data/bluetooth"; } from "../../../../../data/bluetooth";
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info"; import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info";
@customElement("bluetooth-advertisement-monitor") @customElement("bluetooth-advertisement-monitor")
@ -38,6 +40,22 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
@state() private _scanners: BluetoothScannersDetails = {}; @state() private _scanners: BluetoothScannersDetails = {};
@state() private _sourceDevices: Record<string, DeviceRegistryEntry> = {};
@storage({
key: "bluetooth-advertisement-table-grouping",
state: false,
subscribe: false,
})
private _activeGrouping?: string = "source";
@storage({
key: "bluetooth-advertisement-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed: string[] = [];
private _unsub_advertisements?: UnsubscribeFunc; private _unsub_advertisements?: UnsubscribeFunc;
private _unsub_scanners?: UnsubscribeFunc; private _unsub_scanners?: UnsubscribeFunc;
@ -57,6 +75,19 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
this._scanners = scanners; this._scanners = scanners;
} }
); );
const devices = Object.values(this.hass.devices);
const bluetoothDevices = devices.filter((device) =>
device.connections.find((connection) => connection[0] === "bluetooth")
);
this._sourceDevices = Object.fromEntries(
bluetoothDevices.map((device) => {
const connection = device.connections.find(
(c) => c[0] === "bluetooth"
)!;
return [connection[1], device];
})
);
} }
} }
@ -84,21 +115,35 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
hideable: false, hideable: false,
moveable: false, moveable: false,
direction: "asc", direction: "asc",
flex: 2, flex: 1,
}, },
name: { name: {
title: localize("ui.panel.config.bluetooth.name"), title: localize("ui.panel.config.bluetooth.name"),
filterable: true, filterable: true,
sortable: true, sortable: true,
}, },
device: {
title: localize("ui.panel.config.bluetooth.device"),
filterable: true,
sortable: true,
template: (data) => html`${data.device || "-"}`,
},
source: { source: {
title: localize("ui.panel.config.bluetooth.source"), title: localize("ui.panel.config.bluetooth.source"),
filterable: true, filterable: true,
sortable: true, sortable: true,
groupable: true,
},
source_address: {
title: localize("ui.panel.config.bluetooth.source_address"),
filterable: true,
sortable: true,
defaultHidden: true,
}, },
rssi: { rssi: {
title: localize("ui.panel.config.bluetooth.rssi"), title: localize("ui.panel.config.bluetooth.rssi"),
type: "numeric", type: "numeric",
maxWidth: "60px",
sortable: true, sortable: true,
}, },
}; };
@ -108,11 +153,22 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
); );
private _dataWithNamedSourceAndIds = memoizeOne((data) => private _dataWithNamedSourceAndIds = memoizeOne((data) =>
data.map((row) => ({ data.map((row) => {
...row, const device = this._sourceDevices[row.address];
id: row.address, const scannerDevice = this._sourceDevices[row.source];
source: this._scanners[row.source]?.name || row.source, const scanner = this._scanners[row.source];
})) return {
...row,
id: row.address,
source_address: row.source,
source:
scannerDevice?.name_by_user ||
scannerDevice?.name ||
scanner?.name ||
row.source,
device: device?.name_by_user || device?.name || undefined,
};
})
); );
protected render(): TemplateResult { protected render(): TemplateResult {
@ -124,11 +180,23 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
.columns=${this._columns(this.hass.localize)} .columns=${this._columns(this.hass.localize)}
.data=${this._dataWithNamedSourceAndIds(this._data)} .data=${this._dataWithNamedSourceAndIds(this._data)}
@row-click=${this._handleRowClicked} @row-click=${this._handleRowClicked}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
clickable clickable
></hass-tabs-subpage-data-table> ></hass-tabs-subpage-data-table>
`; `;
} }
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) { private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const entry = this._data.find((ent) => ent.address === ev.detail.id); const entry = this._data.find((ent) => ent.address === ev.detail.id);
showBluetoothDeviceInfoDialog(this, { showBluetoothDeviceInfoDialog(this, {

View File

@ -53,8 +53,6 @@ class DialogBluetoothDeviceInfo extends LitElement implements HassDialog {
return html` return html`
<ha-dialog <ha-dialog
open open
scrimClickAction
escapeKeyAction
@closed=${this.closeDialog} @closed=${this.closeDialog}
.heading=${createCloseHeading( .heading=${createCloseHeading(
this.hass, this.hass,

View File

@ -25,7 +25,7 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color"; import { computeCssColor } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatShortDateTime } from "../../../common/datetime/format_date_time"; import { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time";
import { relativeTime } from "../../../common/datetime/relative_time"; import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage"; import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event"; import type { HASSDomEvent } from "../../../common/dom/fire_event";
@ -304,7 +304,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
return html` return html`
${script.last_triggered ${script.last_triggered
? dayDifference > 3 ? dayDifference > 3
? formatShortDateTime( ? formatShortDateTimeWithConditionalYear(
date, date,
this.hass.locale, this.hass.locale,
this.hass.config this.hass.config

View File

@ -173,7 +173,7 @@ class HaPanelHistory extends LitElement {
.endDate=${this._endDate} .endDate=${this._endDate}
extended-presets extended-presets
time-picker time-picker
@change=${this._dateRangeChanged} @value-changed=${this._dateRangeChanged}
></ha-date-range-picker> ></ha-date-range-picker>
<ha-target-picker <ha-target-picker
.hass=${this.hass} .hass=${this.hass}
@ -424,8 +424,8 @@ class HaPanelHistory extends LitElement {
); );
private _dateRangeChanged(ev) { private _dateRangeChanged(ev) {
this._startDate = ev.detail.startDate; this._startDate = ev.detail.value.startDate;
const endDate = ev.detail.endDate; const endDate = ev.detail.value.endDate;
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) { if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
endDate.setDate(endDate.getDate() + 1); endDate.setDate(endDate.getDate() + 1);
endDate.setMilliseconds(endDate.getMilliseconds() - 1); endDate.setMilliseconds(endDate.getMilliseconds() - 1);

View File

@ -93,7 +93,7 @@ export class HaPanelLogbook extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.startDate=${this._time.range[0]} .startDate=${this._time.range[0]}
.endDate=${this._time.range[1]} .endDate=${this._time.range[1]}
@change=${this._dateRangeChanged} @value-changed=${this._dateRangeChanged}
time-picker time-picker
></ha-date-range-picker> ></ha-date-range-picker>
@ -233,8 +233,8 @@ export class HaPanelLogbook extends LitElement {
} }
private _dateRangeChanged(ev) { private _dateRangeChanged(ev) {
const startDate = ev.detail.startDate; const startDate = ev.detail.value.startDate;
const endDate = ev.detail.endDate; const endDate = ev.detail.value.endDate;
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) { if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
endDate.setDate(endDate.getDate() + 1); endDate.setDate(endDate.getDate() + 1);
endDate.setMilliseconds(endDate.getMilliseconds() - 1); endDate.setMilliseconds(endDate.getMilliseconds() - 1);

View File

@ -1,5 +1,16 @@
import type { HassConfig } from "home-assistant-js-websocket"; import type { HassConfig } from "home-assistant-js-websocket";
import { addHours, subHours, differenceInDays } from "date-fns"; import {
differenceInMonths,
subHours,
differenceInDays,
differenceInYears,
startOfYear,
addMilliseconds,
startOfMonth,
addYears,
addMonths,
addHours,
} from "date-fns";
import type { import type {
BarSeriesOption, BarSeriesOption,
CallbackDataParams, CallbackDataParams,
@ -7,10 +18,12 @@ import type {
} from "echarts/types/dist/shared"; } from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../../../../data/translation"; import type { FrontendLocaleData } from "../../../../../data/translation";
import { formatNumber } from "../../../../../common/number/format_number"; import { formatNumber } from "../../../../../common/number/format_number";
import { formatDateVeryShort } from "../../../../../common/datetime/format_date"; import {
formatDateMonthYear,
formatDateVeryShort,
} from "../../../../../common/datetime/format_date";
import { formatTime } from "../../../../../common/datetime/format_time"; import { formatTime } from "../../../../../common/datetime/format_time";
import type { ECOption } from "../../../../../resources/echarts"; import type { ECOption } from "../../../../../resources/echarts";
import { getTimeAxisLabelConfig } from "../../../../../components/chart/axis-label";
export function getSuggestedMax(dayDifference: number, end: Date): number { export function getSuggestedMax(dayDifference: number, end: Date): number {
let suggestedMax = new Date(end); let suggestedMax = new Date(end);
@ -52,23 +65,9 @@ export function getCommonOptions(
const options: ECOption = { const options: ECOption = {
xAxis: { xAxis: {
id: "xAxisMain",
type: "time", type: "time",
min: start.getTime(), min: start,
max: getSuggestedMax(dayDifference, end), max: getSuggestedMax(dayDifference, end),
axisLabel: getTimeAxisLabelConfig(locale, config, dayDifference),
axisLine: {
show: false,
},
splitLine: {
show: true,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
}, },
yAxis: { yAxis: {
type: "value", type: "value",
@ -103,7 +102,6 @@ export function getCommonOptions(
} }
}); });
return [mainItems, compareItems] return [mainItems, compareItems]
.filter((items) => items.length > 0)
.map((items) => .map((items) =>
formatTooltip( formatTooltip(
items, items,
@ -115,6 +113,7 @@ export function getCommonOptions(
formatTotal formatTotal
) )
) )
.filter(Boolean)
.join("<br><br>"); .join("<br><br>");
} }
return formatTooltip( return formatTooltip(
@ -141,14 +140,16 @@ function formatTooltip(
unit?: string, unit?: string,
formatTotal?: (total: number) => string formatTotal?: (total: number) => string
) { ) {
if (!params[0].value) { if (!params[0]?.value) {
return ""; return "";
} }
// when comparing the first value is offset to match the main period // when comparing the first value is offset to match the main period
// and the real date is in the third value // and the real date is in the third value
const date = new Date(params[0].value?.[2] ?? params[0].value?.[0]); const date = new Date(params[0].value?.[2] ?? params[0].value?.[0]);
let period: string; let period: string;
if (dayDifference > 0) { if (dayDifference > 89) {
period = `${formatDateMonthYear(date, locale, config)}`;
} else if (dayDifference > 0) {
period = `${formatDateVeryShort(date, locale, config)}`; period = `${formatDateVeryShort(date, locale, config)}`;
} else { } else {
period = `${ period = `${
@ -198,7 +199,9 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
const buckets = Array.from( const buckets = Array.from(
new Set( new Set(
datasets datasets
.map((dataset) => dataset.data!.map((datapoint) => datapoint![0])) .map((dataset) =>
dataset.data!.map((datapoint) => Number(datapoint![0]))
)
.flat() .flat()
) )
).sort((a, b) => a - b); ).sort((a, b) => a - b);
@ -257,3 +260,25 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
} }
}); });
} }
export function getCompareTransform(start: Date, compareStart?: Date) {
if (!compareStart) {
return (ts: Date) => ts;
}
const compareYearDiff = differenceInYears(start, compareStart);
if (
compareYearDiff !== 0 &&
start.getTime() === startOfYear(start).getTime()
) {
return (ts: Date) => addYears(ts, compareYearDiff);
}
const compareMonthDiff = differenceInMonths(start, compareStart);
if (
compareMonthDiff !== 0 &&
start.getTime() === startOfMonth(start).getTime()
) {
return (ts: Date) => addMonths(ts, compareMonthDiff);
}
const compareOffset = start.getTime() - compareStart.getTime();
return (ts: Date) => addMilliseconds(ts, compareOffset);
}

View File

@ -33,6 +33,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import { import {
fillDataGapsAndRoundCaps, fillDataGapsAndRoundCaps,
getCommonOptions, getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options"; } from "./common/energy-chart-options";
import { storage } from "../../../../common/decorators/storage"; import { storage } from "../../../../common/decorators/storage";
import type { ECOption } from "../../../../resources/echarts"; import type { ECOption } from "../../../../resources/echarts";
@ -314,29 +315,34 @@ export class HuiEnergyDevicesDetailGraphCard
processedData.forEach((device) => { processedData.forEach((device) => {
device.data.forEach((datapoint) => { device.data.forEach((datapoint) => {
totalDeviceConsumption[datapoint[0]] = totalDeviceConsumption[datapoint[compare ? 2 : 0]] =
(totalDeviceConsumption[datapoint[0]] || 0) + datapoint[1]; (totalDeviceConsumption[datapoint[compare ? 2 : 0]] || 0) +
datapoint[1];
}); });
}); });
const compareOffset = compare const compareTransform = getCompareTransform(
? this._start.getTime() - this._compareStart!.getTime() this._start,
: 0; this._compareStart!
);
const untrackedConsumption: BarSeriesOption["data"] = []; const untrackedConsumption: BarSeriesOption["data"] = [];
Object.keys(consumptionData.total).forEach((time) => { Object.keys(consumptionData.total).forEach((time) => {
const ts = Number(time);
const value = const value =
consumptionData.total[time] - (totalDeviceConsumption[time] || 0); consumptionData.total[time] - (totalDeviceConsumption[time] || 0);
const dataPoint = [Number(time), value]; const dataPoint: number[] = [ts, value];
if (compare) { if (compare) {
dataPoint[2] = dataPoint[0]; dataPoint[2] = dataPoint[0];
dataPoint[0] += compareOffset; dataPoint[0] = compareTransform(new Date(ts)).getTime();
} }
untrackedConsumption.push(dataPoint); untrackedConsumption.push(dataPoint);
}); });
// random id to always add untracked at the end
const order = Date.now();
const dataset: BarSeriesOption = { const dataset: BarSeriesOption = {
type: "bar", type: "bar",
cursor: "default", cursor: "default",
id: compare ? "compare-untracked" : "untracked", id: compare ? `compare-untracked-${order}` : `untracked-${order}`,
name: this.hass.localize( name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption" "ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
), ),
@ -372,9 +378,10 @@ export class HuiEnergyDevicesDetailGraphCard
compare = false compare = false
) { ) {
const data: BarSeriesOption[] = []; const data: BarSeriesOption[] = [];
const compareOffset = compare const compareTransform = getCompareTransform(
? this._start.getTime() - this._compareStart!.getTime() this._start,
: 0; this._compareStart!
);
devices.forEach((source, idx) => { devices.forEach((source, idx) => {
const order = sorted_devices.indexOf(source.stat_consumption); const order = sorted_devices.indexOf(source.stat_consumption);
@ -409,7 +416,7 @@ export class HuiEnergyDevicesDetailGraphCard
const dataPoint = [point.start, point.change]; const dataPoint = [point.start, point.change];
if (compare) { if (compare) {
dataPoint[2] = dataPoint[0]; dataPoint[2] = dataPoint[0];
dataPoint[0] += compareOffset; dataPoint[0] = compareTransform(new Date(point.start)).getTime();
} }
consumptionData.push(dataPoint); consumptionData.push(dataPoint);
prevStart = point.start; prevStart = point.start;
@ -419,9 +426,10 @@ export class HuiEnergyDevicesDetailGraphCard
data.push({ data.push({
type: "bar", type: "bar",
cursor: "default", cursor: "default",
// add order to id, otherwise echarts refuses to reorder them
id: compare id: compare
? `compare-${source.stat_consumption}` ? `compare-${source.stat_consumption}-${order}`
: source.stat_consumption, : `${source.stat_consumption}-${order}`,
name: name:
source.name || source.name ||
getStatisticLabel( getStatisticLabel(
@ -438,7 +446,9 @@ export class HuiEnergyDevicesDetailGraphCard
stack: compare ? "devicesCompare" : "devices", stack: compare ? "devicesCompare" : "devices",
}); });
}); });
return data; return sorted_devices.map(
(device) => data.find((d) => (d.id as string).includes(device))!
);
} }
static styles = css` static styles = css`

View File

@ -88,7 +88,7 @@ export class HuiEnergyDevicesGraphCard
<ha-chart-base <ha-chart-base
.hass=${this.hass} .hass=${this.hass}
.data=${this._chartData} .data=${this._chartData}
.options=${this._createOptions(this.hass.themes?.darkMode)} .options=${this._createOptions(this._chartData)}
.height=${`${(this._chartData[0]?.data?.length || 0) * 28 + 50}px`} .height=${`${(this._chartData[0]?.data?.length || 0) * 28 + 50}px`}
@chart-click=${this._handleChartClick} @chart-click=${this._handleChartClick}
></ha-chart-base> ></ha-chart-base>
@ -110,18 +110,17 @@ export class HuiEnergyDevicesGraphCard
} }
private _createOptions = memoizeOne( private _createOptions = memoizeOne(
(darkMode: boolean): ECOption => ({ (data: BarSeriesOption[]): ECOption => ({
xAxis: { xAxis: {
type: "value", type: "value",
name: "kWh", name: "kWh",
splitLine: {
lineStyle: darkMode ? { opacity: 0.15 } : {},
},
}, },
yAxis: { yAxis: {
type: "category", type: "category",
inverse: true, inverse: true,
triggerEvent: true, triggerEvent: true,
// take order from data
data: data[0]?.data?.map((d: any) => d.value[1]),
axisLabel: { axisLabel: {
formatter: this._getDeviceName.bind(this), formatter: this._getDeviceName.bind(this),
overflow: "truncate", overflow: "truncate",

View File

@ -29,6 +29,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import { import {
fillDataGapsAndRoundCaps, fillDataGapsAndRoundCaps,
getCommonOptions, getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options"; } from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts"; import type { ECOption } from "../../../../resources/echarts";
@ -213,9 +214,10 @@ export class HuiEnergyGasGraphCard
compare = false compare = false
) { ) {
const data: BarSeriesOption[] = []; const data: BarSeriesOption[] = [];
const compareOffset = compare const compareTransform = getCompareTransform(
? this._start.getTime() - this._compareStart!.getTime() this._start,
: 0; this._compareStart!
);
gasSources.forEach((source, idx) => { gasSources.forEach((source, idx) => {
let prevStart: number | null = null; let prevStart: number | null = null;
@ -236,10 +238,13 @@ export class HuiEnergyGasGraphCard
if (prevStart === point.start) { if (prevStart === point.start) {
continue; continue;
} }
const dataPoint = [point.start, point.change]; const dataPoint: (Date | string | number)[] = [
point.start,
point.change,
];
if (compare) { if (compare) {
dataPoint[2] = dataPoint[0]; dataPoint[2] = dataPoint[0];
dataPoint[0] += compareOffset; dataPoint[0] = compareTransform(new Date(point.start));
} }
gasConsumptionData.push(dataPoint); gasConsumptionData.push(dataPoint);
prevStart = point.start; prevStart = point.start;

View File

@ -30,6 +30,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import { import {
fillDataGapsAndRoundCaps, fillDataGapsAndRoundCaps,
getCommonOptions, getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options"; } from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts"; import type { ECOption } from "../../../../resources/echarts";
@ -231,9 +232,10 @@ export class HuiEnergySolarGraphCard
compare = false compare = false
) { ) {
const data: BarSeriesOption[] = []; const data: BarSeriesOption[] = [];
const compareOffset = compare const compareTransform = getCompareTransform(
? this._start.getTime() - this._compareStart!.getTime() this._start,
: 0; this._compareStart!
);
solarSources.forEach((source, idx) => { solarSources.forEach((source, idx) => {
let prevStart: number | null = null; let prevStart: number | null = null;
@ -255,10 +257,13 @@ export class HuiEnergySolarGraphCard
if (prevStart === point.start) { if (prevStart === point.start) {
continue; continue;
} }
const dataPoint = [point.start, point.change]; const dataPoint: (Date | string | number)[] = [
point.start,
point.change,
];
if (compare) { if (compare) {
dataPoint[2] = dataPoint[0]; dataPoint[2] = dataPoint[0];
dataPoint[0] += compareOffset; dataPoint[0] = compareTransform(new Date(point.start));
} }
solarProductionData.push(dataPoint); solarProductionData.push(dataPoint);
prevStart = point.start; prevStart = point.start;

View File

@ -27,6 +27,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import { import {
fillDataGapsAndRoundCaps, fillDataGapsAndRoundCaps,
getCommonOptions, getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options"; } from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts"; import type { ECOption } from "../../../../resources/echarts";
@ -476,9 +477,10 @@ export class HuiEnergyUsageGraphCard
(a, b) => Number(a) - Number(b) (a, b) => Number(a) - Number(b)
); );
const compareOffset = compare const compareTransform = getCompareTransform(
? this._start.getTime() - this._compareStart!.getTime() this._start,
: 0; this._compareStart!
);
Object.entries(combinedData).forEach(([type, sources]) => { Object.entries(combinedData).forEach(([type, sources]) => {
Object.entries(sources).forEach(([statId, source]) => { Object.entries(sources).forEach(([statId, source]) => {
@ -494,7 +496,7 @@ export class HuiEnergyUsageGraphCard
]; ];
if (compare) { if (compare) {
dataPoint[2] = dataPoint[0]; dataPoint[2] = dataPoint[0];
dataPoint[0] += compareOffset; dataPoint[0] = compareTransform(dataPoint[0]);
} }
points.push(dataPoint); points.push(dataPoint);
} }

View File

@ -28,6 +28,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import { import {
fillDataGapsAndRoundCaps, fillDataGapsAndRoundCaps,
getCommonOptions, getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options"; } from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts"; import type { ECOption } from "../../../../resources/echarts";
import { formatNumber } from "../../../../common/number/format_number"; import { formatNumber } from "../../../../common/number/format_number";
@ -211,9 +212,10 @@ export class HuiEnergyWaterGraphCard
compare = false compare = false
) { ) {
const data: BarSeriesOption[] = []; const data: BarSeriesOption[] = [];
const compareOffset = compare const compareTransform = getCompareTransform(
? this._start.getTime() - this._compareStart!.getTime() this._start,
: 0; this._compareStart!
);
waterSources.forEach((source, idx) => { waterSources.forEach((source, idx) => {
let prevStart: number | null = null; let prevStart: number | null = null;
@ -234,10 +236,13 @@ export class HuiEnergyWaterGraphCard
if (prevStart === point.start) { if (prevStart === point.start) {
continue; continue;
} }
const dataPoint = [point.start, point.change]; const dataPoint: (Date | string | number)[] = [
point.start,
point.change,
];
if (compare) { if (compare) {
dataPoint[2] = dataPoint[0]; dataPoint[2] = dataPoint[0];
dataPoint[0] += compareOffset; dataPoint[0] = compareTransform(new Date(point.start));
} }
waterConsumptionData.push(dataPoint); waterConsumptionData.push(dataPoint);
prevStart = point.start; prevStart = point.start;

View File

@ -65,7 +65,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
return { return {
columns: 12, columns: 12,
min_columns: 6, min_columns: 6,
min_rows: this._config?.entities?.length || 1, min_rows: 2,
}; };
} }
@ -244,7 +244,8 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
})}`; })}`;
const columns = this._config.grid_options?.columns ?? 12; const columns = this._config.grid_options?.columns ?? 12;
const narrow = Number.isNaN(columns) || Number(columns) < 12; const narrow = typeof columns === "number" && columns <= 12;
const hasFixedHeight = typeof this._config.grid_options?.rows === "number";
return html` return html`
<ha-card> <ha-card>
@ -259,6 +260,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
<div <div
class="content ${classMap({ class="content ${classMap({
"has-header": !!this._config.title, "has-header": !!this._config.title,
"has-rows": !!this._config.grid_options?.rows,
})}" })}"
> >
${this._error ${this._error
@ -283,9 +285,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.minYAxis=${this._config.min_y_axis} .minYAxis=${this._config.min_y_axis}
.maxYAxis=${this._config.max_y_axis} .maxYAxis=${this._config.max_y_axis}
.fitYData=${this._config.fit_y_data || false} .fitYData=${this._config.fit_y_data || false}
.height=${this._config.grid_options?.rows .height=${hasFixedHeight ? "100%" : undefined}
? "100%"
: undefined}
.narrow=${narrow} .narrow=${narrow}
></state-history-charts> ></state-history-charts>
`} `}
@ -303,6 +303,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.card-header { .card-header {
justify-content: space-between; justify-content: space-between;
display: flex; display: flex;
padding-bottom: 0;
} }
.card-header ha-icon-next { .card-header ha-icon-next {
--mdc-icon-button-size: 24px; --mdc-icon-button-size: 24px;
@ -310,7 +311,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
color: var(--primary-text-color); color: var(--primary-text-color);
} }
.content { .content {
padding: 16px; padding: 0 16px 8px 16px;
flex: 1; flex: 1;
} }
.has-header { .has-header {
@ -318,6 +319,10 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
} }
state-history-charts { state-history-charts {
height: 100%; height: 100%;
--timeline-top-margin: 16px;
}
.has-rows {
--chart-max-height: 100%;
} }
`; `;
} }

View File

@ -6,7 +6,10 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import "../../../components/ha-card"; import "../../../components/ha-card";
import { getEnergyDataCollection } from "../../../data/energy"; import { getEnergyDataCollection } from "../../../data/energy";
import { getSuggestedPeriod } from "./energy/common/energy-chart-options"; import {
getSuggestedMax,
getSuggestedPeriod,
} from "./energy/common/energy-chart-options";
import type { import type {
Statistics, Statistics,
StatisticsMetaData, StatisticsMetaData,
@ -255,8 +258,13 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
return nothing; return nothing;
} }
const hasFixedHeight = typeof this._config.grid_options?.rows === "number";
return html` return html`
<ha-card .header=${this._config.title}> <ha-card>
${this._config.title
? html`<h1 class="card-header">${this._config.title}</h1>`
: nothing}
<div <div
class="content ${classMap({ class="content ${classMap({
"has-header": !!this._config.title, "has-header": !!this._config.title,
@ -274,11 +282,20 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
.unit=${this._unit} .unit=${this._unit}
.minYAxis=${this._config.min_y_axis} .minYAxis=${this._config.min_y_axis}
.maxYAxis=${this._config.max_y_axis} .maxYAxis=${this._config.max_y_axis}
.startTime=${this._energyStart}
.endTime=${this._energyEnd && this._energyStart
? getSuggestedMax(
differenceInDays(this._energyEnd, this._energyStart),
this._energyEnd
)
: undefined}
.fitYData=${this._config.fit_y_data || false} .fitYData=${this._config.fit_y_data || false}
.hideLegend=${this._config.hide_legend || false} .hideLegend=${this._config.hide_legend || false}
.logarithmicScale=${this._config.logarithmic_scale || false} .logarithmicScale=${this._config.logarithmic_scale || false}
.daysToShow=${this._config.days_to_show || DEFAULT_DAYS_TO_SHOW} .daysToShow=${this._energyStart && this._energyEnd
.height=${this._config.grid_options?.rows ? "100%" : undefined} ? differenceInDays(this._energyEnd, this._energyStart)
: this._config.days_to_show || DEFAULT_DAYS_TO_SHOW}
.height=${hasFixedHeight ? "100%" : undefined}
></statistics-chart> ></statistics-chart>
</div> </div>
</ha-card> </ha-card>
@ -358,8 +375,12 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
} }
.card-header {
padding-bottom: 0;
}
.content { .content {
padding: 16px; padding: 16px;
padding-top: 0;
flex: 1; flex: 1;
} }
.has-header { .has-header {

View File

@ -246,7 +246,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
.startDate=${this._startDate} .startDate=${this._startDate}
.endDate=${this._endDate || new Date()} .endDate=${this._endDate || new Date()}
.ranges=${this._ranges} .ranges=${this._ranges}
@change=${this._dateRangeChanged} @value-changed=${this._dateRangeChanged}
minimal minimal
></ha-date-range-picker> ></ha-date-range-picker>
</div> </div>
@ -346,7 +346,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
private _dateRangeChanged(ev) { private _dateRangeChanged(ev) {
const weekStartsOn = firstWeekdayIndex(this.hass.locale); const weekStartsOn = firstWeekdayIndex(this.hass.locale);
this._startDate = calcDate( this._startDate = calcDate(
ev.detail.startDate, ev.detail.value.startDate,
startOfDay, startOfDay,
this.hass.locale, this.hass.locale,
this.hass.config, this.hass.config,
@ -355,7 +355,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
} }
); );
this._endDate = calcDate( this._endDate = calcDate(
ev.detail.endDate, ev.detail.value.endDate,
endOfDay, endOfDay,
this.hass.locale, this.hass.locale,
this.hass.config, this.hass.config,

View File

@ -201,9 +201,7 @@ export class HuiGenericEntityRow extends LitElement {
padding-inline-end: 8px; padding-inline-end: 8px;
flex: 1 1 30%; flex: 1 1 30%;
min-height: 40px; min-height: 40px;
display: flex; align-content: center;
flex-direction: column;
justify-content: center;
} }
.info, .info,
.info > * { .info > * {
@ -238,8 +236,7 @@ export class HuiGenericEntityRow extends LitElement {
.value { .value {
direction: ltr; direction: ltr;
min-height: 40px; min-height: 40px;
display: flex; align-content: center;
align-items: center;
} }
`; `;
} }

View File

@ -462,7 +462,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
} }
private _openMoreInfo() { private _openMoreInfo() {
if (this._browserPlayer) { if (this.entityId === BROWSER_PLAYER) {
return; return;
} }
fireEvent(this, "hass-more-info", { entityId: this.entityId }); fireEvent(this, "hass-more-info", { entityId: this.entityId });

View File

@ -29,21 +29,11 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
return; return;
} }
if ( if (
// !__DEV__ && (!__DEV__ &&
ev.message.includes("ResizeObserver loop limit exceeded") || ev.message.includes("ResizeObserver loop limit exceeded")) ||
ev.message.includes( ev.message.includes(
"ResizeObserver loop completed with undelivered notifications" "ResizeObserver loop completed with undelivered notifications"
) || )
(ev.error.stack.includes("echarts") &&
(ev.message.includes(
"Cannot read properties of undefined (reading 'hostedBy')"
) ||
ev.message.includes(
"Cannot read properties of undefined (reading 'scale')"
) ||
ev.message.includes(
"Cannot read properties of null (reading 'innerHTML')"
)))
) { ) {
ev.preventDefault(); ev.preventDefault();
ev.stopImmediatePropagation(); ev.stopImmediatePropagation();

View File

@ -2223,7 +2223,8 @@
"backup_type": "Type", "backup_type": "Type",
"type": { "type": {
"manual": "Manual", "manual": "Manual",
"automatic": "Automatic" "automatic": "Automatic",
"addon_update": "Add-on update"
}, },
"locations": "Locations", "locations": "Locations",
"create": { "create": {
@ -2392,17 +2393,23 @@
"download": { "download": {
"decryption_unsupported_title": "Decryption unsupported", "decryption_unsupported_title": "Decryption unsupported",
"decryption_unsupported": "Decryption is not supported for this backup. The downloaded backup will remain encrypted and can't be opened. To restore it, you will need the encryption key.", "decryption_unsupported": "Decryption is not supported for this backup. The downloaded backup will remain encrypted and can't be opened. To restore it, you will need the encryption key.",
"incorrect_entered_encryption_key": "The entered encryption key was incorrect, try again or download the encrypted backup. The encrypted backup can't be opened. To restore it, you will need the encryption key.",
"download_encrypted": "Download encrypted",
"incorrect_current_encryption_key": "This backup is encrypted with a different encryption key than the current one, please enter the encryption key of this backup.",
"error_check_title": "Error checking backup", "error_check_title": "Error checking backup",
"error_check_description": "An error occurred while checking the backup, please try again. Error message: {error}" "error_check_description": "An error occurred while checking the backup, please try again. Error message: {error}",
"title": "Download backup",
"description": "This backup is encrypted with a different encryption key than the current one, please enter the encryption key of this backup.",
"download_backup_encrypted": "You can still {download_it_encrypted}. To restore it, you will need the encryption key.",
"download_it_encrypted": "download the backup encrypted",
"encryption_key": "Encryption key",
"incorrect_encryption_key": "Incorrect encryption key",
"decryption_not_supported": "Decryption not supported",
"download": "Download"
} }
}, },
"agents": { "agents": {
"cloud_agent_description": "Note: It stores only the most recent backup, regardless of your retention settings, with a maximum size of 5 GB.", "cloud_agent_description": "Note: It stores only the most recent backup, regardless of your retention settings, with a maximum size of 5 GB.",
"cloud_agent_no_subcription": "You currently do not have an active Home Assistant Cloud subscription.", "cloud_agent_no_subcription": "You currently do not have an active Home Assistant Cloud subscription.",
"network_mount_agent_description": "Network storage", "network_mount_agent_description": "Network storage",
"unavailable_agents": "Unavailable locations",
"no_agents": "No locations configured", "no_agents": "No locations configured",
"encryption_turned_off": "Encryption turned off", "encryption_turned_off": "Encryption turned off",
"local_agent": "This system" "local_agent": "This system"
@ -2560,6 +2567,7 @@
"title": "My backups", "title": "My backups",
"automatic": "{count} automatic {count, plural,\n one {backup}\n other {backups}\n}", "automatic": "{count} automatic {count, plural,\n one {backup}\n other {backups}\n}",
"manual": "{count} manual {count, plural,\n one {backup}\n other {backups}\n}", "manual": "{count} manual {count, plural,\n one {backup}\n other {backups}\n}",
"addon_update": "{count} add-on update {count, plural,\n one {backup}\n other {backups}\n}",
"total_size": "{size} in total", "total_size": "{size} in total",
"show_all": "Show all backups" "show_all": "Show all backups"
}, },
@ -2678,19 +2686,19 @@
"encryption": { "encryption": {
"title": "Encryption", "title": "Encryption",
"description": "All your backups are encrypted by default to keep your data private and secure.", "description": "All your backups are encrypted by default to keep your data private and secure.",
"location_encrypted": "This location is encrypted", "location_encrypted": "Backups made to this location will be encrypted",
"location_unencrypted": "This location is unencrypted", "location_unencrypted": "Backups made to this location will be unencrypted",
"location_encrypted_description": "Your data private and secure by securing it with your encryption key.", "location_encrypted_description": "Your data is private and secure by encrypting backups with your encryption key.",
"location_encrypted_cloud_description": "Home Assistant Cloud is the privacy-focused cloud. This is why it will only accept encrypted backups and why we dont store your encryption key.", "location_encrypted_cloud_description": "Home Assistant Cloud is the privacy-focused cloud. This is why it will only accept encrypted backups and why we dont store your encryption key.",
"location_encrypted_cloud_learn_more": "Learn more", "location_encrypted_cloud_learn_more": "Learn more",
"location_unencrypted_description": "Please keep your backups private and secure.", "location_unencrypted_description": "Please keep your backups private and secure.",
"encryption_turn_on": "Turn on", "encryption_turn_on": "Turn on",
"encryption_turn_off": "Turn off", "encryption_turn_off": "Turn off",
"encryption_turn_off_confirm_title": "Turn encryption off?", "encryption_turn_off_confirm_title": "Turn encryption off?",
"encryption_turn_off_confirm_text": "All your next backups will not be encrypted for this location. Please keep your backups private and secure.", "encryption_turn_off_confirm_text": "After confirming, backups created will be unencrypted for this location. Please ensure your backups remain private and secure.",
"encryption_turn_off_confirm_action": "Turn encryption off", "encryption_turn_off_confirm_action": "Turn encryption off",
"warning_encryption_turn_off": "Encryption turned off", "warning_encryption_turn_off": "Encryption turned off",
"warning_encryption_turn_off_description": "All your next backups will not be encrypted." "warning_encryption_turn_off_description": "Backups will be unencrypted."
} }
} }
}, },
@ -4584,6 +4592,7 @@
"account_created": "Account created! Check your email for instructions on how to activate your account." "account_created": "Account created! Check your email for instructions on how to activate your account."
}, },
"account": { "account": {
"download_support_package": "Download support package",
"reset_cloud_data": "Reset cloud data", "reset_cloud_data": "Reset cloud data",
"reset_data_confirm_title": "Reset cloud data?", "reset_data_confirm_title": "Reset cloud data?",
"reset_data_confirm_text": "This will reset all your cloud settings. This includes your remote connection, Google Assistant and Amazon Alexa integrations. This action cannot be undone.", "reset_data_confirm_text": "This will reset all your cloud settings. This includes your remote connection, Google Assistant and Amazon Alexa integrations. This action cannot be undone.",
@ -5330,6 +5339,8 @@
"name": "Name", "name": "Name",
"source": "Source", "source": "Source",
"rssi": "RSSI", "rssi": "RSSI",
"source_address": "Source address",
"device": "Device",
"device_information": "Device information", "device_information": "Device information",
"advertisement_data": "Advertisement data", "advertisement_data": "Advertisement data",
"manufacturer_data": "Manufacturer data", "manufacturer_data": "Manufacturer data",

View File

@ -21,5 +21,11 @@ export function measureTextWidth(
} }
context.font = `${fontSize}px ${fontFamily}`; context.font = `${fontSize}px ${fontFamily}`;
return Math.ceil(context.measureText(text).width); const textMetrics = context.measureText(text);
return Math.ceil(
Math.max(
textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft,
textMetrics.width
)
);
} }