Compare commits

..

2 Commits

Author SHA1 Message Date
J. Nick Koston dd00b51f21 Adjust WebSocket ping timeout to 15 seconds
5 seconds was too low to prevent the UI from reloading
when connecting the WebSocket during startup or on
a high latancy connection

This problem presented as the UI reloading over
and over again because it could never respond
to the ping in time on high latancy connections.

At startup it usually only did this once so it
went unnoticed in most cases.

This ping was added in #18934
2025-02-20 11:46:59 -06:00
J. Nick Koston 64b886eea0 Reduce size of address column on Bluetooth Advertisement monitor 2025-01-29 12:51:56 -06:00
65 changed files with 959 additions and 2333 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20250221.0"
version = "20250129.0"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"
-10
View File
@@ -1,4 +1,3 @@
import memoizeOne from "memoize-one";
import { theme2hex } from "./convert-color";
export const COLORS = [
@@ -75,12 +74,3 @@ export function getGraphColorByIndex(
getColorByIndex(index);
return theme2hex(themeColor);
}
export const getAllGraphColors = memoizeOne(
(style: CSSStyleDeclaration) =>
COLORS.map((_color, index) => getGraphColorByIndex(index, style)),
(newArgs: [CSSStyleDeclaration], lastArgs: [CSSStyleDeclaration]) =>
// this is not ideal, but we need to memoize the colors
newArgs[0].getPropertyValue("--graph-color-1") ===
lastArgs[0].getPropertyValue("--graph-color-1")
);
+48 -37
View File
@@ -1,4 +1,5 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { XAXisOption } from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../data/translation";
import {
formatDateMonth,
@@ -6,46 +7,56 @@ import {
formatDateVeryShort,
formatDateWeekdayShort,
} from "../../common/datetime/format_date";
import {
formatTime,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
import { formatTime } from "../../common/datetime/format_time";
export function formatTimeLabel(
value: number | Date,
export function getLabelFormatter(
locale: FrontendLocaleData,
config: HassConfig,
minutesDifference: number
dayDifference = 0
) {
const dayDifference = minutesDifference / 60 / 24;
const date = new Date(value);
if (dayDifference > 88) {
return date.getMonth() === 0
? `{bold|${formatDateMonthYear(date, locale, config)}}`
: formatDateMonth(date, locale, config);
}
if (dayDifference > 35) {
return date.getDate() === 1
? `{bold|${formatDateVeryShort(date, locale, config)}}`
: formatDateVeryShort(date, locale, config);
}
if (dayDifference > 7) {
const label = formatDateVeryShort(date, locale, config);
return date.getDate() === 1 ? `{bold|${label}}` : label;
}
if (dayDifference > 2) {
return formatDateWeekdayShort(date, locale, config);
}
if (minutesDifference && minutesDifference < 5) {
return formatTimeWithSeconds(date, locale, config);
}
if (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0
) {
return (value: number | Date) => {
const date = new Date(value);
if (dayDifference > 88) {
return date.getMonth() === 0
? `{bold|${formatDateMonthYear(date, locale, config)}}`
: formatDateMonth(date, locale, config);
}
if (dayDifference > 35) {
return date.getDate() === 1
? `{bold|${formatDateVeryShort(date, locale, config)}}`
: formatDateVeryShort(date, locale, config);
}
if (dayDifference > 7) {
const label = formatDateVeryShort(date, locale, config);
return date.getDate() === 1 ? `{bold|${label}}` : label;
}
if (dayDifference > 2) {
return formatDateWeekdayShort(date, locale, config);
}
// show only date for the beginning of the day
return `{bold|${formatDateVeryShort(date, locale, config)}}`;
}
return formatTime(date, locale, config);
if (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0
) {
return `{bold|${formatDateVeryShort(date, locale, config)}}`;
}
return formatTime(date, locale, config);
};
}
export function getTimeAxisLabelConfig(
locale: FrontendLocaleData,
config: HassConfig,
dayDifference?: number
): XAXisOption["axisLabel"] {
return {
formatter: getLabelFormatter(locale, config, dayDifference),
rich: {
bold: {
fontWeight: "bold",
},
},
hideOverlap: true,
};
}
+79 -325
View File
@@ -1,30 +1,25 @@
import { consume } from "@lit-labs/context";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, html, nothing, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { mdiRestart } from "@mdi/js";
import { differenceInMinutes } from "date-fns";
import type { DataZoomComponentOption } from "echarts/components";
import type { EChartsType } from "echarts/core";
import type { DataZoomComponentOption } from "echarts/components";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type {
ECElementEvent,
XAXisOption,
YAXisOption,
} from "echarts/types/dist/shared";
import 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 { consume } from "@lit-labs/context";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { themesContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts";
import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { ensureArray } from "../../common/array/ensure-array";
import type { ECOption } from "../../resources/echarts";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { Themes } from "../../data/ws-themes";
import { themesContext } from "../../data/context";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
@@ -49,10 +44,6 @@ export class HaChartBase extends LitElement {
@state() private _isZoomed = false;
@state() private _zoomRatio = 1;
@state() private _minutesDifference = 24 * 60;
private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window;
@@ -68,16 +59,12 @@ export class HaChartBase extends LitElement {
private _listeners: (() => void)[] = [];
private _originalZrFlush?: () => void;
public disconnectedCallback() {
super.disconnectedCallback();
while (this._listeners.length) {
this._listeners.pop()!();
}
this.chart?.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
}
public connectedCallback() {
@@ -88,19 +75,19 @@ export class HaChartBase extends LitElement {
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
if (this._reducedMotion !== matches) {
this._reducedMotion = matches;
this._setChartOptions({ animation: !this._reducedMotion });
}
this._reducedMotion = matches;
this.chart?.setOption({ animation: !this._reducedMotion });
})
);
// Add keyboard event listeners
const handleKeyDown = (ev: KeyboardEvent) => {
if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) {
if ((isMac && ev.metaKey) || (!isMac && ev.ctrlKey)) {
this._modifierPressed = true;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
}
}
};
@@ -109,7 +96,9 @@ export class HaChartBase extends LitElement {
if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) {
this._modifierPressed = false;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
}
}
};
@@ -127,37 +116,47 @@ export class HaChartBase extends LitElement {
}
public willUpdate(changedProps: PropertyValues): void {
if (!this.chart) {
super.willUpdate(changedProps);
if (!this.hasUpdated || !this.chart) {
return;
}
if (changedProps.has("_themes")) {
this._setupChart();
return;
}
let chartOptions: ECOption = {};
if (changedProps.has("data")) {
chartOptions.series = this.data;
this.chart.setOption(
{ series: this.data },
{ lazyUpdate: true, replaceMerge: ["series"] }
);
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
} else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
chartOptions.dataZoom = this._getDataZoomConfig();
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (changedProps.has("options") || changedProps.has("_isZoomed")) {
this.chart.setOption(this._createOptions(), {
lazyUpdate: true,
// if we replace the whole object, it will reset the dataZoom
replaceMerge: [
"xAxis",
"yAxis",
"dataZoom",
"dataset",
"tooltip",
"legend",
"grid",
"visualMap",
],
});
}
}
protected render() {
return html`
<div
class=${classMap({
"chart-container": true,
"has-legend": !!this.options?.legend,
})}
class="chart-container"
style=${styleMap({
height: this.height ?? `${this._getDefaultHeight()}px`,
})}
@wheel=${this._handleWheel}
>
<div class="chart"></div>
${this._isZoomed
@@ -174,14 +173,6 @@ 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() {
if (this._loading) return;
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
@@ -192,9 +183,10 @@ export class HaChartBase extends LitElement {
}
const echarts = (await import("../../resources/echarts")).default;
echarts.registerTheme("custom", this._createTheme());
this.chart = echarts.init(container, "custom");
this.chart = echarts.init(
container,
this._themes.darkMode ? "dark" : "light"
);
this.chart.on("legendselectchanged", (params: any) => {
if (this.externalHidden) {
const isSelected = params.selected[params.name];
@@ -208,7 +200,6 @@ export class HaChartBase extends LitElement {
this.chart.on("datazoom", (e: any) => {
const { start, end } = e.batch?.[0] ?? e;
this._isZoomed = start !== 0 || end !== 100;
this._zoomRatio = (end - start) / 100;
});
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
@@ -239,67 +230,31 @@ export class HaChartBase extends LitElement {
type: "inside",
orient: "horizontal",
filterMode: "none",
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
moveOnMouseMove: this._isZoomed,
preventDefaultMouseMove: this._isZoomed,
zoomLock: !this._isTouchDevice && !this._modifierPressed,
};
}
private _createOptions(): ECOption {
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 darkMode = this._themes.darkMode ?? false;
const options = {
backgroundColor: "transparent",
animation: !this._reducedMotion,
darkMode: this._themes.darkMode ?? false,
darkMode,
aria: {
show: true,
},
dataZoom: this._getDataZoomConfig(),
...this.options,
xAxis,
legend: this.options?.legend
? {
// we should create our own theme but this is a quick fix for now
inactiveColor: darkMode ? "#444" : "#ccc",
...this.options.legend,
}
: undefined,
};
const isMobile = window.matchMedia(
@@ -313,242 +268,44 @@ export class HaChartBase extends LitElement {
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
tooltip.triggerOn = "click";
});
options.tooltip = tooltips;
}
return options;
}
private _createTheme() {
const style = getComputedStyle(this);
return {
color: getAllGraphColors(style),
backgroundColor: "transparent",
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
fontFamily: "Roboto, Noto, sans-serif",
},
title: {
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
},
subtextStyle: {
color: style.getPropertyValue("--secondary-text-color"),
},
},
line: {
lineStyle: {
width: 1.5,
},
symbolSize: 1,
symbol: "circle",
smooth: false,
},
bar: {
itemStyle: {
barBorderWidth: 1.5,
},
},
categoryAxis: {
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: false,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
valueAxis: {
axisLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisTick: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
logAxis: {
axisLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisTick: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
timeAxis: {
axisLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisTick: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
legend: {
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
},
inactiveColor: style.getPropertyValue("--disabled-text-color"),
pageIconColor: style.getPropertyValue("--primary-text-color"),
pageIconInactiveColor: style.getPropertyValue("--disabled-text-color"),
pageTextStyle: {
color: style.getPropertyValue("--secondary-text-color"),
},
},
tooltip: {
backgroundColor: style.getPropertyValue("--card-background-color"),
borderColor: style.getPropertyValue("--divider-color"),
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
fontSize: 12,
},
axisPointer: {
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
crossStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
},
timeline: {},
};
}
private _getDefaultHeight() {
return Math.max(this.clientWidth / 2, 200);
}
private _setChartOptions(options: ECOption) {
if (!this.chart) {
return;
}
if (!this._originalZrFlush) {
const dataSize = ensureArray(this.data).reduce(
(acc, series) => acc + (series.data as any[]).length,
0
);
if (dataSize > 10000) {
// delay the last bit of the render to avoid blocking the main thread
// this is not that impactful with sampling enabled but it doesn't hurt to have it
const zr = this.chart.getZr();
this._originalZrFlush = zr.flush;
zr.flush = () => {
setTimeout(() => {
this._originalZrFlush?.call(zr);
}, 5);
};
}
}
const replaceMerge = options.series ? ["series"] : [];
this.chart.setOption(options, { replaceMerge });
return Math.max(this.clientWidth / 2, 400);
}
private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
}
private _handleWheel(e: WheelEvent) {
// if the window is not focused, we don't receive the keydown events but scroll still works
if (!this.options?.dataZoom) {
const modifierPressed = (isMac && e.metaKey) || (!isMac && e.ctrlKey);
if (modifierPressed) {
e.preventDefault();
}
if (modifierPressed !== this._modifierPressed) {
this._modifierPressed = modifierPressed;
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
}
}
}
static styles = css`
:host {
display: block;
position: relative;
letter-spacing: normal;
}
.chart-container {
position: relative;
max-height: var(--chart-max-height, 350px);
max-height: var(--chart-max-height, 400px);
}
.chart {
width: 100%;
@@ -564,9 +321,6 @@ export class HaChartBase extends LitElement {
color: var(--primary-color);
border: 1px solid var(--divider-color);
}
.has-legend .zoom-reset {
top: 64px;
}
`;
}
+132 -266
View File
@@ -4,6 +4,7 @@ import { property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
import type { YAXisOption } from "echarts/types/dist/shared";
import { differenceInDays } from "date-fns";
import { styleMap } from "lit/directives/style-map";
import { getGraphColorByIndex } from "../../common/color/colors";
import { computeRTL } from "../../common/util/compute_rtl";
@@ -17,10 +18,10 @@ import {
getNumberFormatOptions,
formatNumber,
} from "../../common/number/format_number";
import { getTimeAxisLabelConfig } from "./axis-label";
import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
@@ -71,12 +72,8 @@ export class StateHistoryChartLine extends LitElement {
@state() private _chartOptions?: ECOption;
private _hiddenStats = new Set<string>();
@state() private _yWidth = 25;
@state() private _visualMap?: VisualMapComponentOption[];
private _chartTime: Date = new Date();
protected render() {
@@ -87,104 +84,49 @@ export class StateHistoryChartLine extends LitElement {
.options=${this._chartOptions}
.height=${this.height}
style=${styleMap({ height: this.height })}
external-hidden
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
></ha-chart-base>
`;
}
private _renderTooltip = (params: any) => {
const time = params[0].axisValue;
const title =
formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
) + "<br>";
const datapoints: Record<string, any>[] = [];
this._chartData.forEach((dataset, index) => {
if (
dataset.tooltip?.show === false ||
this._hiddenStats.has(dataset.name as string)
)
return;
const param = params.find(
(p: Record<string, any>) => p.seriesIndex === index
);
if (param) {
datapoints.push(param);
return;
}
// If the datapoint is not found, we need to find the last datapoint before the current time
let lastData: any;
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;
private _renderTooltip(params) {
return params
.map((param, index: number) => {
let value = `${formatNumber(
param.value[1] as number,
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._entityIds[param.seriesIndex]]
)
)} ${this.unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
}
if (!lastData) return;
datapoints.push({
seriesName: dataset.name,
seriesIndex: index,
value: lastData,
// HTML copied from echarts. May change based on options
marker: `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
});
});
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return (
title +
datapoints
.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
let value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
const time =
index === 0
? formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
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);
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}
`;
})
.join("<br>");
}
public willUpdate(changedProps: PropertyValues) {
@@ -210,48 +152,53 @@ export class StateHistoryChartLine extends LitElement {
changedProps.has("minYAxis") ||
changedProps.has("maxYAxis") ||
changedProps.has("fitYData") ||
changedProps.has("_chartData") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_visualMap") ||
changedProps.has("_yWidth")
) {
const dayDifference = differenceInDays(this.endTime, this.startTime);
const rtl = computeRTL(this.hass);
let minYAxis: number | ((values: { min: number }) => number) | undefined =
this.minYAxis;
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
this.maxYAxis;
if (typeof minYAxis === "number") {
if (this.fitYData) {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => Math.floor(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 }) => Math.ceil(max > 0 ? max * 1.05 : max * 0.95);
}
const splitLineStyle = this.hass.themes?.darkMode
? { opacity: 0.15 }
: {};
this._chartOptions = {
xAxis: {
type: "time",
min: this.startTime,
max: this.endTime,
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
axisLine: {
show: false,
},
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
min: this.fitYData ? this.minYAxis : undefined,
max: this.fitYData ? this.maxYAxis : undefined,
position: rtl ? "right" : "left",
scale: true,
nameGap: 2,
nameTextStyle: {
align: "left",
},
axisLine: {
show: false,
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
axisLabel: {
margin: 5,
@@ -271,8 +218,6 @@ export class StateHistoryChartLine extends LitElement {
} as YAXisOption,
legend: {
show: this.showNames,
type: "scroll",
animationDurationUpdate: 400,
icon: "circle",
padding: [20, 0],
},
@@ -282,11 +227,37 @@ export class StateHistoryChartLine extends LitElement {
right: rtl ? Math.max(this.paddingYAxis, this._yWidth) : 1,
bottom: 30,
},
visualMap: this._visualMap,
visualMap: this._chartData
.map((_, seriesIndex) => {
const dataIndex = this._datasetToDataIndex[seriesIndex];
const data = this.data[dataIndex];
if (!data.statistics || data.statistics.length === 0) {
return false;
}
// render stat data with a slightly transparent line
const firstStateTS =
data.states[0]?.last_changed ?? this.endTime.getTime();
return {
show: false,
seriesIndex,
dimension: 0,
pieces: [
{
max: firstStateTS - 0.01,
colorAlpha: 0.5,
},
{
min: firstStateTS,
colorAlpha: 1,
},
],
};
})
.filter(Boolean) as VisualMapComponentOption[],
tooltip: {
trigger: "axis",
appendTo: document.body,
formatter: this._renderTooltip,
formatter: this._renderTooltip.bind(this),
},
};
}
@@ -336,28 +307,21 @@ export class StateHistoryChartLine extends LitElement {
prevValues = datavalues;
};
const addDataSet = (
id: string,
nameY: string,
color?: string,
fill = false
) => {
const addDataSet = (nameY: string, color?: string, fill = false) => {
if (!color) {
color = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
}
data.push({
id,
id: nameY,
data: [],
type: "line",
cursor: "default",
name: nameY,
color,
symbol: "circle",
symbolSize: 1,
step: "end",
sampling: "minmax",
animationDurationUpdate: 0,
symbolSize: 1,
lineStyle: {
width: fill ? 0 : 1.5,
},
@@ -411,23 +375,13 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.target_temp_low
);
addDataSet(
states.entity_id + "-current_temperature",
this.showNames
? this.hass.localize("ui.card.climate.current_temperature", {
name: name,
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
`${this.hass.localize("ui.card.climate.current_temperature", {
name: name,
})}`
);
if (hasHeat) {
addDataSet(
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"
),
`${this.hass.localize("ui.card.climate.heating", { name: name })}`,
computedStyles.getPropertyValue("--state-climate-heat-color"),
true
);
@@ -436,12 +390,7 @@ export class StateHistoryChartLine extends LitElement {
}
if (hasCool) {
addDataSet(
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"
),
`${this.hass.localize("ui.card.climate.cooling", { name: name })}`,
computedStyles.getPropertyValue("--state-climate-cool-color"),
true
);
@@ -451,40 +400,22 @@ export class StateHistoryChartLine extends LitElement {
if (hasTargetRange) {
addDataSet(
states.entity_id + "-target_temperature_mode",
this.showNames
? this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.high"),
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_high.name"
)
`${this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.high"),
})}`
);
addDataSet(
states.entity_id + "-target_temperature_mode_low",
this.showNames
? this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.low"),
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_low.name"
)
`${this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.low"),
})}`
);
} else {
addDataSet(
states.entity_id + "-target_temperature",
this.showNames
? this.hass.localize(
"ui.card.climate.target_temperature_entity",
{
name: name,
}
)
: this.hass.localize(
"component.climate.entity_component._.state_attributes.temperature.name"
)
`${this.hass.localize("ui.card.climate.target_temperature_entity", {
name: name,
})}`
);
}
@@ -537,29 +468,19 @@ export class StateHistoryChartLine extends LitElement {
);
addDataSet(
states.entity_id + "-target_humidity",
this.showNames
? this.hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.humidity.name"
)
`${this.hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})}`
);
if (hasCurrent) {
addDataSet(
states.entity_id + "-current_humidity",
this.showNames
? this.hass.localize(
"ui.card.humidifier.current_humidity_entity",
{
name: name,
}
)
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
)
`${this.hass.localize(
"ui.card.humidifier.current_humidity_entity",
{
name: name,
}
)}`
);
}
@@ -567,40 +488,25 @@ export class StateHistoryChartLine extends LitElement {
// If action attribute is not available, we shade the area when the device is on
if (hasHumidifying) {
addDataSet(
states.entity_id + "-humidifying",
this.showNames
? this.hass.localize("ui.card.humidifier.humidifying", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
),
`${this.hass.localize("ui.card.humidifier.humidifying", {
name: name,
})}`,
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else if (hasDrying) {
addDataSet(
states.entity_id + "-drying",
this.showNames
? this.hass.localize("ui.card.humidifier.drying", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.drying"
),
`${this.hass.localize("ui.card.humidifier.drying", {
name: name,
})}`,
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else {
addDataSet(
states.entity_id + "-on",
this.showNames
? this.hass.localize("ui.card.humidifier.on_entity", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state.on"
),
`${this.hass.localize("ui.card.humidifier.on_entity", {
name: name,
})}`,
undefined,
true
);
@@ -633,7 +539,7 @@ export class StateHistoryChartLine extends LitElement {
pushData(new Date(entityState.last_changed), series);
});
} else {
addDataSet(states.entity_id, name);
addDataSet(name);
let lastValue: number;
let lastDate: Date;
@@ -702,46 +608,6 @@ export class StateHistoryChartLine extends LitElement {
this._chartData = datasets;
this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex;
const visualMap: VisualMapComponentOption[] = [];
this._chartData.forEach((_, seriesIndex) => {
const dataIndex = this._datasetToDataIndex[seriesIndex];
const data = this.data[dataIndex];
if (!data.statistics || data.statistics.length === 0) {
return;
}
// render stat data with a slightly transparent line
const firstStateTS =
data.states[0]?.last_changed ?? this.endTime.getTime();
visualMap.push({
show: false,
seriesIndex,
dimension: 0,
pieces: [
{
max: firstStateTS - 0.01,
colorAlpha: 0.5,
},
{
min: firstStateTS,
colorAlpha: 1,
},
],
});
});
this._visualMap = visualMap.length > 0 ? visualMap : undefined;
}
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);
@@ -8,6 +8,7 @@ import type {
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { differenceInDays } from "date-fns";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration";
import { computeRTL } from "../../common/util/compute_rtl";
@@ -21,6 +22,7 @@ import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
import { getTimeAxisLabelConfig } from "./axis-label";
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@@ -65,7 +67,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as ECOption["series"]}
.data=${this._chartData}
@chart-click=${this._handleChartClick}
></ha-chart-base>
`;
@@ -127,12 +129,10 @@ export class StateHistoryChartTimeline extends LitElement {
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => {
const { value, name, marker, seriesName } = Array.isArray(params)
const { value, name, marker } = Array.isArray(params)
? params[0]
: params;
const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const title = `<h4 style="text-align: center; margin: 0;">${value![0]}</h4>`;
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
@@ -183,12 +183,13 @@ export class StateHistoryChartTimeline extends LitElement {
private _createOptions() {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const maxInternalLabelWidth = narrow ? 70 : 165;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(this.hass);
const dayDifference = differenceInDays(this.endTime, this.startTime);
this._chartOptions = {
xAxis: {
type: "time",
@@ -196,10 +197,21 @@ export class StateHistoryChartTimeline extends LitElement {
max: this.endTime,
axisTick: {
show: true,
lineStyle: {
opacity: 0.4,
},
},
splitLine: {
show: false,
},
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: "category",
@@ -214,18 +226,14 @@ export class StateHistoryChartTimeline extends LitElement {
},
axisLabel: {
show: showNames,
width: labelWidth,
width: labelWidth - labelMargin,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
formatter: (label: string) => {
const width = Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
);
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
@@ -270,9 +278,8 @@ export class StateHistoryChartTimeline extends LitElement {
let prevState: string | null = null;
let locState: string | null = null;
let prevLastChanged = startTime;
const entityDisplay: string = this.showNames
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
: "";
const entityDisplay: string =
names[stateInfo.entity_id] || stateInfo.name;
const dataRow: unknown[] = [];
stateInfo.data.forEach((entityState) => {
@@ -300,7 +307,7 @@ export class StateHistoryChartTimeline extends LitElement {
);
dataRow.push({
value: [
stateInfo.entity_id,
entityDisplay,
prevLastChanged,
newLastChanged,
locState,
@@ -326,7 +333,7 @@ export class StateHistoryChartTimeline extends LitElement {
);
dataRow.push({
value: [
stateInfo.entity_id,
entityDisplay,
prevLastChanged,
endTime,
locState,
@@ -339,10 +346,9 @@ export class StateHistoryChartTimeline extends LitElement {
});
}
datasets.push({
id: stateInfo.entity_id,
data: dataRow,
name: entityDisplay,
dimensions: ["id", "start", "end", "name", "color", "textColor"],
dimensions: ["index", "start", "end", "name", "color", "textColor"],
type: "custom",
encode: {
x: [1, 2],
@@ -358,10 +364,10 @@ export class StateHistoryChartTimeline extends LitElement {
private _handleChartClick(e: CustomEvent<ECElementEvent>): void {
if (e.detail.targetType === "axisLabel") {
const dataset = this._chartData[e.detail.dataIndex];
const dataset = this.data[e.detail.dataIndex];
if (dataset) {
fireEvent(this, "hass-more-info", {
entityId: dataset.id as string,
entityId: dataset.entity_id,
});
}
}
+2 -9
View File
@@ -135,7 +135,7 @@ export class StateHistoryCharts extends LitElement {
return html``;
}
if (!Array.isArray(item)) {
return html`<div class="entry-container line">
return html`<div class="entry-container">
<state-history-chart-line
.hass=${this.hass}
.unit=${item.unit}
@@ -157,7 +157,7 @@ export class StateHistoryCharts extends LitElement {
></state-history-chart-line>
</div> `;
}
return html`<div class="entry-container timeline">
return html`<div class="entry-container">
<state-history-chart-timeline
.hass=${this.hass}
.data=${item}
@@ -299,9 +299,6 @@ export class StateHistoryCharts extends LitElement {
.entry-container {
width: 100%;
}
.entry-container.line {
flex: 1;
}
@@ -316,10 +313,6 @@ export class StateHistoryCharts extends LitElement {
padding-inline-end: 1px;
}
.entry-container.timeline:first-child {
margin-top: var(--timeline-top-margin);
}
.entry-container:not(:first-child) {
border-top: 2px solid var(--divider-color);
margin-top: 16px;
+64 -139
View File
@@ -1,22 +1,15 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type {
BarSeriesOption,
LineSeriesOption,
} from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { 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 {
Statistics,
StatisticsMetaData,
@@ -28,9 +21,16 @@ import {
getStatisticMetadata,
statisticsHaveType,
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
import { computeRTL } from "../../common/util/compute_rtl";
import type { ECOption } from "../../resources/echarts";
import {
formatNumber,
getNumberFormatOptions,
} from "../../common/number/format_number";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { getTimeAxisLabelConfig } from "./axis-label";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -56,8 +56,6 @@ export class StatisticsChart extends LitElement {
@property() public unit?: string;
@property({ attribute: false }) public startTime?: Date;
@property({ attribute: false }) public endTime?: Date;
@property({ attribute: false, type: Array })
@@ -126,10 +124,7 @@ export class StatisticsChart extends LitElement {
changedProps.has("fitYData") ||
changedProps.has("logarithmicScale") ||
changedProps.has("hideLegend") ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("_legendData") ||
changedProps.has("_chartData")
changedProps.has("_legendData")
) {
this._createOptions();
}
@@ -186,31 +181,18 @@ export class StatisticsChart extends LitElement {
this.requestUpdate("_hiddenStats");
}
private _renderTooltip = (params: any) => {
const rendered: Record<string, boolean> = {};
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return params
private _renderTooltip = (params: any) =>
params
.map((param, index: number) => {
if (rendered[param.seriesName]) return "";
rendered[param.seriesName] = true;
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
// max series can have 3 values, as the second value is the max-min to form a band
const rawValue = String(param.value[2] ?? param.value[1]);
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(
rawValue,
// max series can have 3 values, as the second value is the max-min to form a band
(param.value[2] ?? param.value[1]) as number,
this.hass.locale,
options
)}${unit}`;
getNumberFormatOptions(
undefined,
this.hass.entities[this._statisticIds[param.seriesIndex]]
)
)} ${this.unit}`;
const time =
index === 0
@@ -220,70 +202,36 @@ export class StatisticsChart extends LitElement {
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
return `${time}${param.marker} ${param.seriesName}: ${value}
`;
})
.filter(Boolean)
.join("<br>");
};
private _createOptions() {
const splitLineStyle = this.hass.themes?.darkMode ? { opacity: 0.15 } : {};
const dayDifference = this.daysToShow ?? 1;
let minYAxis: number | ((values: { min: number }) => number) | undefined =
this.minYAxis;
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
this.maxYAxis;
if (typeof minYAxis === "number") {
if (this.fitYData) {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => Math.floor(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 }) => Math.ceil(max > 0 ? max * 1.05 : max * 0.95);
}
const endTime = this.endTime ?? new Date();
let startTime = this.startTime;
if (!startTime) {
// set start time to the earliest point in the chart data
this._chartData.forEach((series) => {
if (!Array.isArray(series.data) || !series.data[0]) return;
const firstPoint = series.data[0] as any;
const timestamp = Array.isArray(firstPoint)
? firstPoint[0]
: firstPoint.value?.[0];
if (timestamp && (!startTime || new Date(timestamp) < startTime)) {
startTime = new Date(timestamp);
}
});
if (!startTime) {
// Calculate default start time based on dayDifference
startTime = new Date(
endTime.getTime() - dayDifference * 24 * 3600 * 1000
);
}
}
this._chartOptions = {
xAxis: [
{
id: "xAxis",
type: "time",
min: startTime,
max: this.endTime,
},
{
id: "hiddenAxis",
type: "time",
xAxis: {
type: "time",
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
axisLine: {
show: false,
},
],
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
@@ -292,24 +240,24 @@ export class StatisticsChart extends LitElement {
align: "left",
},
position: computeRTL(this.hass) ? "right" : "left",
scale: true,
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
// @ts-ignore
scale: this.chartType !== "bar",
min: this.fitYData ? undefined : this.minYAxis,
max: this.fitYData ? undefined : this.maxYAxis,
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
},
legend: {
show: !this.hideLegend,
type: "scroll",
animationDurationUpdate: 400,
icon: "circle",
padding: [20, 0],
data: this._legendData,
},
grid: {
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
left: 1,
left: 20,
right: 1,
bottom: 0,
containLabel: true,
@@ -421,12 +369,10 @@ export class StatisticsChart extends LitElement {
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push(
this._transformDataValue([prevEndTime, ...prevValues[i]!])
);
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
}
d.data!.push(this._transformDataValue([start, ...dataValues[i]!]));
d.data!.push([start, ...dataValues[i]!]);
});
prevValues = dataValues;
prevEndTime = end;
@@ -475,14 +421,9 @@ export class StatisticsChart extends LitElement {
displayedLegend = displayedLegend || showLegend;
}
statTypes.push(type);
const borderColor =
band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color;
const backgroundColor = band ? color + "3F" : color + "7F";
const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`,
type: this.chartType,
smooth: this.chartType === "line" ? 0.4 : false,
smoothMonotone: "x",
cursor: "default",
data: [],
name: name
@@ -492,9 +433,8 @@ export class StatisticsChart extends LitElement {
: this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
),
symbol: "none",
sampling: "minmax",
animationDurationUpdate: 0,
symbol: "circle",
symbolSize: 0,
lineStyle: {
width: 1.5,
},
@@ -502,15 +442,21 @@ export class StatisticsChart extends LitElement {
this.chartType === "bar"
? {
borderRadius: [4, 4, 0, 0],
borderColor,
borderColor:
band && hasMean
? color + (this.hideLegend ? "00" : "7F")
: color,
borderWidth: 1.5,
}
: undefined,
color: this.chartType === "bar" ? backgroundColor : borderColor,
color: band ? color + "3F" : color + "7F",
};
if (band && this.chartType === "line") {
series.stack = `band-${statistic_id}`;
series.stackStrategy = "all";
(series as LineSeriesOption).symbol = "none";
(series as LineSeriesOption).lineStyle = {
opacity: 0,
};
if (drawBands && type === "max") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
@@ -543,7 +489,7 @@ export class StatisticsChart extends LitElement {
}
} else if (type === "max" && this.chartType === "line") {
const max = stat.max || 0;
val.push(Math.abs(max - (stat.min || 0)));
val.push(max - (stat.min || 0));
val.push(max);
} else {
val.push(stat[type] ?? null);
@@ -572,7 +518,6 @@ export class StatisticsChart extends LitElement {
color,
type: this.chartType,
data: [],
xAxisIndex: 1,
});
});
@@ -584,26 +529,6 @@ export class StatisticsChart extends LitElement {
this._statisticIds = statisticIds;
}
private _transformDataValue(val: [Date, ...(number | null)[]]) {
if (this.chartType === "bar" && val[1] && val[1] < 0) {
return { value: val, itemStyle: { borderRadius: [0, 0, 4, 4] } };
}
return val;
}
private _clampYAxis(value?: number | ((values: any) => number)) {
if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value
if (typeof value === "number") {
return Math.max(value, 0.1);
}
if (typeof value === "function") {
return (values: any) => Math.max(value(values), 0.1);
}
}
return value;
}
static styles = css`
:host {
display: block;
+4 -1
View File
@@ -329,12 +329,15 @@ export class HaBaseTimeInput extends LitElement {
:host([clearable]) {
position: relative;
}
:host {
display: block;
}
.time-input-wrap-wrap {
display: flex;
}
.time-input-wrap {
display: flex;
flex: var(--time-input-flex, unset);
flex: 1;
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
overflow: hidden;
position: relative;
+5 -41
View File
@@ -9,13 +9,12 @@ import {
endOfMonth,
endOfWeek,
endOfYear,
isThisYear,
startOfDay,
startOfMonth,
startOfWeek,
startOfYear,
isThisYear,
} from "date-fns";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -23,18 +22,16 @@ import { ifDefined } from "lit/directives/if-defined";
import { calcDate, shiftDateRange } from "../common/datetime/calc_date";
import { firstWeekdayIndex } from "../common/datetime/first_weekday";
import {
formatShortDateTime,
formatShortDateTimeWithYear,
formatShortDateTime,
} from "../common/datetime/format_date_time";
import { useAmPm } from "../common/datetime/use_am_pm";
import { fireEvent } from "../common/dom/fire_event";
import { TimeZone } from "../data/translation";
import type { HomeAssistant } from "../types";
import "./date-range-picker";
import "./ha-icon-button";
import "./ha-textarea";
import "./ha-icon-button-next";
import "./ha-icon-button-prev";
import "./ha-textarea";
export type DateRangePickerRanges = Record<string, [Date, Date]>;
@@ -200,15 +197,14 @@ export class HaDateRangePicker extends LitElement {
?auto-apply=${this.autoApply}
time-picker=${this.timePicker}
twentyfour-hours=${this._hour24format}
start-date=${this._formatDate(this.startDate)}
end-date=${this._formatDate(this.endDate)}
start-date=${this.startDate.toISOString()}
end-date=${this.endDate.toISOString()}
?ranges=${this.ranges !== false}
opening-direction=${ifDefined(
this.openingDirection || this._calcedOpeningDirection
)}
first-day=${firstWeekdayIndex(this.hass.locale)}
language=${this.hass.locale.language}
@change=${this._handleChange}
>
<div slot="input" class="date-range-inputs" @click=${this._handleClick}>
${!this.minimal
@@ -329,31 +325,9 @@ export class HaDateRangePicker extends LitElement {
}
private _applyDateRange() {
if (this.hass.locale.time_zone === TimeZone.server) {
const dateRangePicker = this._dateRangePicker;
const startDate = fromZonedTime(
dateRangePicker.start,
this.hass.config.time_zone
);
const endDate = fromZonedTime(
dateRangePicker.end,
this.hass.config.time_zone
);
dateRangePicker.clickRange([startDate, endDate]);
}
this._dateRangePicker.clickedApply();
}
private _formatDate(date: Date): string {
if (this.hass.locale.time_zone === TimeZone.server) {
return toZonedTime(date, this.hass.config.time_zone).toISOString();
}
return date.toISOString();
}
private get _dateRangePicker() {
const dateRangePicker = this.shadowRoot!.querySelector(
"date-range-picker"
@@ -384,16 +358,6 @@ export class HaDateRangePicker extends LitElement {
}
}
private _handleChange(ev: CustomEvent) {
ev.stopPropagation();
const startDate = ev.detail.startDate;
const endDate = ev.detail.endDate;
fireEvent(this, "value-changed", {
value: { startDate, endDate },
});
}
static styles = css`
ha-icon-button {
+6 -13
View File
@@ -64,13 +64,9 @@ export class HaNetwork extends LitElement {
>
</ha-checkbox>
</span>
<span slot="heading" data-for="auto_configure">
${this.hass.localize(
"ui.panel.config.network.adapter.auto_configure"
)}
</span>
<span slot="heading" data-for="auto_configure"> Auto Configure </span>
<span slot="description" data-for="auto_configure">
${this.hass.localize("ui.panel.config.network.adapter.detected")}:
Detected:
${format_auto_detected_interfaces(this.networkConfig.adapters)}
</span>
</ha-settings-row>
@@ -89,21 +85,18 @@ export class HaNetwork extends LitElement {
</ha-checkbox>
</span>
<span slot="heading">
${this.hass.localize(
"ui.panel.config.network.adapter.adapter"
)}:
${adapter.name}
Adapter: ${adapter.name}
${adapter.default
? html`<ha-svg-icon .path=${mdiStar}></ha-svg-icon>
(${this.hass.localize("ui.common.default")})`
: nothing}
(Default)`
: ""}
</span>
<span slot="description">
${format_addresses([...adapter.ipv4, ...adapter.ipv6])}
</span>
</ha-settings-row>`
)
: nothing}
: ""}
`;
}
+15 -9
View File
@@ -1,4 +1,4 @@
import { css, html, LitElement, nothing, svg } from "lit";
import { css, html, LitElement, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { BRANCH_HEIGHT, SPACING } from "./hat-graph-const";
@@ -41,8 +41,8 @@ export class HatGraphBranch extends LitElement {
branches.push({
x: width / 2 + total_width,
height,
start: c.hasAttribute("graph-start"),
end: c.hasAttribute("graph-end"),
start: c.hasAttribute("graphStart"),
end: c.hasAttribute("graphEnd"),
track: c.hasAttribute("track"),
});
total_width += width;
@@ -65,8 +65,11 @@ export class HatGraphBranch extends LitElement {
return html`
<slot name="head"></slot>
${!this.start
? html`
<svg id="top" width=${this._totalWidth}>
? svg`
<svg
id="top"
width="${this._totalWidth}"
>
${this._branches.map((branch) =>
branch.start
? ""
@@ -83,7 +86,7 @@ export class HatGraphBranch extends LitElement {
)}
</svg>
`
: nothing}
: ""}
<div id="branches">
<svg id="lines" width=${this._totalWidth} height=${this._maxHeight}>
${this._branches.map((branch) => {
@@ -104,8 +107,11 @@ export class HatGraphBranch extends LitElement {
</div>
${!this.short
? html`
<svg id="bottom" width=${this._totalWidth}>
? svg`
<svg
id="bottom"
width="${this._totalWidth}"
>
${this._branches.map((branch) => {
if (branch.end) return "";
return svg`
@@ -122,7 +128,7 @@ export class HatGraphBranch extends LitElement {
})}
</svg>
`
: nothing}
: ""}
`;
}
+1 -28
View File
@@ -1,8 +1,6 @@
import { memoize } from "@fullcalendar/core/internal";
import { setHours, setMinutes } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import checkValidDate from "../common/datetime/check_valid_date";
import {
formatDateTime,
formatDateTimeNumeric,
@@ -13,6 +11,7 @@ import type { HomeAssistant } from "../types";
import { fileDownload } from "../util/file_download";
import { domainToName } from "./integration";
import type { FrontendLocaleData } from "./translation";
import checkValidDate from "../common/datetime/check_valid_date";
export const enum BackupScheduleRecurrence {
NEVER = "never",
@@ -105,9 +104,6 @@ export interface BackupContent {
name: string;
agents: Record<string, BackupContentAgent>;
failed_agent_ids?: string[];
extra_metadata?: {
"supervisor.addon_update"?: string;
};
with_automatic_settings: boolean;
}
@@ -323,29 +319,6 @@ export const computeBackupAgentName = (
export const computeBackupSize = (backup: BackupContent) =>
Math.max(...Object.values(backup.agents).map((agent) => agent.size));
export type BackupType = "automatic" | "manual" | "addon_update";
const BACKUP_TYPE_ORDER: BackupType[] = ["automatic", "manual", "addon_update"];
export const getBackupTypes = memoize((isHassio: boolean) =>
isHassio
? BACKUP_TYPE_ORDER
: BACKUP_TYPE_ORDER.filter((type) => type !== "addon_update")
);
export const computeBackupType = (
backup: BackupContent,
isHassio: boolean
): BackupType => {
if (backup.with_automatic_settings) {
return "automatic";
}
if (isHassio && backup.extra_metadata?.["supervisor.addon_update"] != null) {
return "addon_update";
}
return "manual";
};
export const compareAgents = (a: string, b: string) => {
const isLocalA = isLocalAgent(a);
const isLocalB = isLocalAgent(b);
-3
View File
@@ -181,6 +181,3 @@ export const updateCloudGoogleEntityConfig = (
export const cloudSyncGoogleAssistant = (hass: HomeAssistant) =>
hass.callApi("POST", "cloud/google_actions/sync");
export const fetchSupportPackage = (hass: HomeAssistant) =>
hass.callApi<string>("GET", "cloud/support_package");
+6 -9
View File
@@ -244,23 +244,20 @@ export const restoreBackup = async (
type: HassioBackupDetail["type"],
backupSlug: string,
backupDetails: HassioPartialBackupCreateParams | HassioFullBackupCreateParams,
useBackupUrl: boolean
useSnapshotUrl: boolean
): Promise<void> => {
if (hass) {
await hass.callApi<HassioResponse<{ job_id: string }>>(
"POST",
`hassio/${useBackupUrl ? "backups" : "snapshots"}/${backupSlug}/restore/${type}`,
`hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`,
backupDetails
);
} else {
await handleFetchPromise(
fetch(
`/api/hassio/${useBackupUrl ? "backups" : "snapshots"}/${backupSlug}/restore/${type}`,
{
method: "POST",
body: JSON.stringify(backupDetails),
}
)
fetch(`/api/hassio/backups/${backupSlug}/restore/${type}`, {
method: "POST",
body: JSON.stringify(backupDetails),
})
);
}
};
@@ -65,8 +65,7 @@ class StepFlowCreateEntry extends LitElement {
if (
devices.length !== 1 ||
devices[0].primary_config_entry !== this.step.result?.entry_id ||
this.step.result.domain === "voip"
devices[0].primary_config_entry !== this.step.result?.entry_id
) {
return;
}
@@ -448,10 +448,6 @@ class MoreInfoUpdate extends LitElement {
box-sizing: border-box;
margin-bottom: -16px;
margin-top: -4px;
--md-sys-color-surface: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
}
ha-md-list-item {
@@ -47,8 +47,6 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
@state() private _assistConfiguration?: AssistSatelliteConfiguration;
@state() private _error?: string;
private _previousSteps: STEP[] = [];
private _nextStep?: STEP;
@@ -167,86 +165,79 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
"update"
)}
></ha-voice-assistant-setup-step-update>`
: this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: assistEntityState?.state === UNAVAILABLE
? html`<ha-alert alert-type="error"
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.not_available"
)}</ha-alert
>`
: this._step === STEP.CHECK
? html`<ha-voice-assistant-setup-step-check
: assistEntityState?.state === UNAVAILABLE
? this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.not_available"
)
: this._step === STEP.CHECK
? html`<ha-voice-assistant-setup-step-check
.hass=${this.hass}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-check>`
: this._step === STEP.WAKEWORD
? html`<ha-voice-assistant-setup-step-wake-word
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-check>`
: this._step === STEP.WAKEWORD
? html`<ha-voice-assistant-setup-step-wake-word
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
.deviceEntities=${this._deviceEntities(
this._params.deviceId,
this.hass.entities
)}
></ha-voice-assistant-setup-step-wake-word>`
: this._step === STEP.CHANGE_WAKEWORD
.deviceEntities=${this._deviceEntities(
this._params.deviceId,
this.hass.entities
)}
></ha-voice-assistant-setup-step-wake-word>`
: this._step === STEP.CHANGE_WAKEWORD
? html`
<ha-voice-assistant-setup-step-change-wake-word
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-change-wake-word>
`
: this._step === STEP.AREA
? html`
<ha-voice-assistant-setup-step-change-wake-word
<ha-voice-assistant-setup-step-area
.hass=${this.hass}
.deviceId=${this._params.deviceId}
></ha-voice-assistant-setup-step-area>
`
: this._step === STEP.PIPELINE
? html`<ha-voice-assistant-setup-step-pipeline
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-change-wake-word>
`
: this._step === STEP.AREA
? html`
<ha-voice-assistant-setup-step-area
></ha-voice-assistant-setup-step-pipeline>`
: this._step === STEP.CLOUD
? html`<ha-voice-assistant-setup-step-cloud
.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}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-pipeline>`
: this._step === STEP.CLOUD
? html`<ha-voice-assistant-setup-step-cloud
></ha-voice-assistant-setup-step-cloud>`
: this._step === STEP.LOCAL
? html`<ha-voice-assistant-setup-step-local
.hass=${this.hass}
></ha-voice-assistant-setup-step-cloud>`
: this._step === STEP.LOCAL
? html`<ha-voice-assistant-setup-step-local
.assistConfiguration=${this
._assistConfiguration}
></ha-voice-assistant-setup-step-local>`
: this._step === STEP.SUCCESS
? html`<ha-voice-assistant-setup-step-success
.hass=${this.hass}
.assistConfiguration=${this
._assistConfiguration}
></ha-voice-assistant-setup-step-local>`
: this._step === STEP.SUCCESS
? html`<ha-voice-assistant-setup-step-success
.hass=${this.hass}
.assistConfiguration=${this
._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-success>`
: nothing}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-success>`
: nothing}
</div>
</ha-dialog>
`;
}
private async _fetchAssistConfiguration() {
try {
this._assistConfiguration = await fetchAssistSatelliteConfiguration(
this.hass,
this._findDomainEntityId(
this._params!.deviceId,
this.hass.entities,
"assist_satellite"
)!
);
} catch (err: any) {
this._error = err.message;
}
this._assistConfiguration = await fetchAssistSatelliteConfiguration(
this.hass,
this._findDomainEntityId(
this._params!.deviceId,
this.hass.entities,
"assist_satellite"
)!
);
return this._assistConfiguration;
}
private _goToPreviousStep() {
@@ -302,10 +293,6 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
.skip-btn {
margin-top: 6px;
}
ha-alert {
margin: 24px;
display: block;
}
`,
];
}
@@ -85,7 +85,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
<div class="rows">
${this.assistConfiguration &&
this.assistConfiguration.available_wake_words.length > 1
? html`<div class="row">
? html` <div class="row">
<ha-select
.label=${"Wake word"}
@closed=${stopPropagation}
@@ -44,15 +44,6 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
protected override willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has("assistConfiguration")) {
if (
this.assistConfiguration &&
!this.assistConfiguration.available_wake_words.length
) {
this._nextStep();
}
}
if (changedProperties.has("assistEntityId")) {
this._detected = false;
this._muteSwitchEntity = this.deviceEntities?.find(
@@ -144,16 +135,13 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
>`
: nothing}
</div>
${this.assistConfiguration &&
this.assistConfiguration.available_wake_words.length > 1
? html`<div class="footer centered">
<ha-button @click=${this._changeWakeWord}
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word"
)}</ha-button
>
</div>`
: nothing}`;
<div class="footer centered">
<ha-button @click=${this._changeWakeWord}
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word"
)}</ha-button
>
</div>`;
}
private async _listenWakeWord() {
@@ -106,7 +106,6 @@ export class HaConfigApplicationCredentials extends LitElement {
},
actions: {
title: "",
label: localize("ui.panel.config.generic.headers.actions"),
type: "overflow-menu",
showNarrow: true,
hideable: false,
@@ -329,9 +329,6 @@ class DialogAreaDetail extends LitElement {
return [
haStyleDialog,
css`
ha-textfield {
display: block;
}
ha-aliases-editor,
ha-entity-picker,
ha-floor-picker,
@@ -1,4 +1,4 @@
import { mdiCog, mdiDelete, mdiHarddisk, mdiNas } from "@mdi/js";
import { mdiCog, mdiHarddisk, mdiNas } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -41,6 +41,13 @@ class HaBackupConfigAgents extends LitElement {
@state() private value?: string[];
private _availableAgents = memoizeOne(
(agents: BackupAgent[], cloudStatus: CloudStatus) =>
agents.filter(
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
)
);
private get _value() {
return this.value ?? DEFAULT_AGENTS;
}
@@ -79,84 +86,19 @@ class HaBackupConfigAgents extends LitElement {
return "";
}
private _availableAgents = memoizeOne(
(agents: BackupAgent[], cloudStatus: CloudStatus) =>
agents.filter(
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
)
);
private _unavailableAgents = memoizeOne(
(
agents: BackupAgent[],
cloudStatus: CloudStatus,
selectedAgentIds: string[]
) => {
const availableAgentIds = this._availableAgents(agents, cloudStatus).map(
(agent) => agent.agent_id
);
return selectedAgentIds
.filter((agent) => !availableAgentIds.includes(agent))
.map<BackupAgent>((id) => ({
agent_id: id,
name: id.split(".")[1] || id, // Use the id as name as it is not available in the list
}));
}
);
private _renderAgentIcon(agentId: string) {
if (isLocalAgent(agentId)) {
return html`
<ha-svg-icon .path=${mdiHarddisk} slot="start"></ha-svg-icon>
`;
}
if (isNetworkMountAgent(agentId)) {
return html`<ha-svg-icon .path=${mdiNas} slot="start"></ha-svg-icon>`;
}
const domain = computeDomain(agentId);
return html`
<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];
const agents = this._availableAgents(this.agents, this.cloudStatus);
return html`
${allAgents.length > 0
${agents.length > 0
? html`
<ha-md-list>
${availableAgents.map((agent) => {
${agents.map((agent) => {
const agentId = agent.agent_id;
const domain = computeDomain(agentId);
const name = computeBackupAgentName(
this.hass.localize,
agentId,
allAgents
this.agents
);
const description = this._description(agentId);
const noCloudSubscription =
@@ -166,7 +108,32 @@ class HaBackupConfigAgents extends LitElement {
return html`
<ha-md-list-item>
${this._renderAgentIcon(agentId)}
${isLocalAgent(agentId)
? html`
<ha-svg-icon .path=${mdiHarddisk} slot="start">
</ha-svg-icon>
`
: isNetworkMountAgent(agentId)
? html`
<ha-svg-icon
.path=${mdiNas}
slot="start"
></ha-svg-icon>
`
: html`
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
`}
<div slot="headline" class="name">${name}</div>
${description
? html`<div slot="supporting-text">${description}</div>`
@@ -184,44 +151,14 @@ class HaBackupConfigAgents extends LitElement {
<ha-switch
slot="end"
id=${agentId}
.checked=${this._value.includes(agentId)}
.disabled=${noCloudSubscription &&
!this._value.includes(agentId)}
.checked=${!noCloudSubscription &&
this._value.includes(agentId)}
.disabled=${noCloudSubscription}
@change=${this._agentToggled}
></ha-switch>
</ha-md-list-item>
`;
})}
${unavailableAgents.length > 0 && this.showSettings
? html`
<p class="heading">
${this.hass.localize(
"ui.panel.config.backup.agents.unavailable_agents"
)}
</p>
${unavailableAgents.map((agent) => {
const agentId = agent.agent_id;
const name = computeBackupAgentName(
this.hass.localize,
agentId,
allAgents
);
return html`
<ha-md-list-item>
${this._renderAgentIcon(agentId)}
<div slot="headline" class="name">${name}</div>
<ha-icon-button
id=${agentId}
slot="end"
path=${mdiDelete}
@click=${this._deleteAgent}
></ha-icon-button>
</ha-md-list-item>
`;
})}
`
: nothing}
</ha-md-list>
`
: html`
@@ -237,13 +174,6 @@ class HaBackupConfigAgents extends LitElement {
navigate(`/config/backup/location/${agentId}`);
}
private _deleteAgent(ev): void {
ev.stopPropagation();
const agentId = ev.currentTarget.id;
this.value = this._value.filter((agent) => agent !== agentId);
fireEvent(this, "value-changed", { value: this.value });
}
private _agentToggled(ev) {
ev.stopPropagation();
const value = ev.currentTarget.checked;
@@ -255,8 +185,19 @@ class HaBackupConfigAgents extends LitElement {
this.value = this._value.filter((agent) => agent !== agentId);
}
const availableAgents = this._availableAgents(
this.agents,
this.cloudStatus
);
// Ensure we don't have duplicates, agents exist in the list and cloud is logged in
this.value = [...new Set(this.value)];
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 });
}
@@ -378,9 +378,8 @@ class HaBackupConfigData extends LitElement {
}
@media all and (max-width: 450px) {
ha-md-select {
min-width: 140px;
width: 140px;
--md-filled-field-content-space: 0;
min-width: 160px;
width: 160px;
}
}
`;
@@ -46,7 +46,7 @@ enum BackupScheduleTime {
}
interface RetentionData {
type: "copies" | "days" | "forever";
type: "copies" | "days";
value: number;
}
@@ -55,7 +55,7 @@ const RETENTION_PRESETS: Record<
RetentionData
> = {
copies_3: { type: "copies", value: 3 },
forever: { type: "forever", value: 0 },
forever: { type: "days", value: 0 },
};
const SCHEDULE_OPTIONS = [
@@ -79,10 +79,7 @@ const computeRetentionPreset = (
data: RetentionData
): RetentionPreset | undefined => {
for (const [key, value] of Object.entries(RETENTION_PRESETS)) {
if (
value.type === data.type &&
(value.type === RetentionPreset.FOREVER || value.value === data.value)
) {
if (value.type === data.type && value.value === data.value) {
return key as RetentionPreset;
}
}
@@ -95,7 +92,7 @@ interface FormData {
time?: string | null;
days: BackupDay[];
retention: {
type: "copies" | "days" | "forever";
type: "copies" | "days";
value: number;
};
}
@@ -145,12 +142,7 @@ class HaBackupConfigSchedule extends LitElement {
? config.schedule.days
: [],
retention: {
type:
config.retention.days === null && config.retention.copies === null
? "forever"
: config.retention.days != null
? "days"
: "copies",
type: config.retention.days != null ? "days" : "copies",
value: config.retention.days ?? config.retention.copies ?? 3,
},
};
@@ -168,11 +160,9 @@ class HaBackupConfigSchedule extends LitElement {
: [],
},
retention:
data.retention.type === "forever"
? { days: null, copies: null }
: data.retention.type === "days"
? { days: data.retention.value, copies: null }
: { copies: data.retention.value, days: null },
data.retention.type === "days"
? { days: data.retention.value, copies: null }
: { copies: data.retention.value, days: null },
};
fireEvent(this, "value-changed", { value: this.value });
@@ -413,11 +403,11 @@ class HaBackupConfigSchedule extends LitElement {
backup_create: html`<a
href=${documentationUrl(
this.hass,
"/integrations/backup/#action-backupcreate_automatic"
"/integrations/backup#example-backing-up-every-night-at-300-am"
)}
target="_blank"
rel="noopener noreferrer"
>backup.create_automatic</a
>backup.create</a
>`,
})}</ha-tip
>
@@ -491,19 +481,9 @@ class HaBackupConfigSchedule extends LitElement {
private _retentionPresetChanged(ev) {
ev.stopPropagation();
const target = ev.currentTarget as HaMdSelect;
let value = target.value as RetentionPreset;
// custom needs to have a type of days or copies, set it to default copies 3
if (
value === RetentionPreset.CUSTOM &&
this._retentionPreset === RetentionPreset.FOREVER
) {
this._retentionPreset = value;
value = RetentionPreset.COPIES_3;
} else {
this._retentionPreset = value;
}
const value = target.value as RetentionPreset;
this._retentionPreset = value;
if (value !== RetentionPreset.CUSTOM) {
const data = this._getData(this.value);
const retention = RETENTION_PRESETS[value];
@@ -513,7 +493,7 @@ class HaBackupConfigSchedule extends LitElement {
}
this._setData({
...data,
retention,
retention: RETENTION_PRESETS[value],
});
}
}
@@ -524,7 +504,6 @@ class HaBackupConfigSchedule extends LitElement {
const value = parseInt(target.value);
const clamped = clamp(value, MIN_VALUE, MAX_VALUE);
const data = this._getData(this.value);
target.value = clamped.toString();
this._setData({
...data,
retention: {
@@ -558,22 +537,14 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-list-item {
--md-item-overflow: visible;
}
ha-md-select {
ha-md-select,
ha-time-input {
min-width: 210px;
}
ha-time-input {
min-width: 194px;
--time-input-flex: 1;
}
@media all and (max-width: 450px) {
ha-md-select {
min-width: 160px;
width: 160px;
--md-filled-field-content-space: 0;
}
ha-md-select,
ha-time-input {
min-width: 145px;
width: 145px;
min-width: 160px;
}
}
ha-md-textfield#value {
@@ -582,16 +553,6 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-select#type {
min-width: 100px;
}
@media all and (max-width: 450px) {
ha-md-textfield#value {
min-width: 60px;
margin: 0 -8px;
}
ha-md-select#type {
min-width: 120px;
width: 120px;
}
}
ha-expansion-panel {
--expansion-panel-summary-padding: 0 16px;
--expansion-panel-content-padding: 0 16px;
@@ -1,19 +1,16 @@
import { mdiCalendarSync, mdiGestureTap, mdiPuzzle } from "@mdi/js";
import { mdiCalendarSync, mdiGestureTap } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import type { BackupContent, BackupType } from "../../../../../data/backup";
import {
computeBackupSize,
computeBackupType,
getBackupTypes,
type BackupContent,
} from "../../../../../data/backup";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
@@ -24,12 +21,6 @@ interface BackupStats {
size: number;
}
const TYPE_ICONS: Record<BackupType, string> = {
automatic: mdiCalendarSync,
manual: mdiGestureTap,
addon_update: mdiPuzzle,
};
const computeBackupStats = (backups: BackupContent[]): BackupStats =>
backups.reduce(
(stats, backup) => {
@@ -46,22 +37,23 @@ class HaBackupOverviewBackups extends LitElement {
@property({ attribute: false }) public backups: BackupContent[] = [];
private _stats = memoizeOne(
(
backups: BackupContent[],
isHassio: boolean
): [BackupType, BackupStats][] =>
getBackupTypes(isHassio).map((type) => {
const backupsOfType = backups.filter(
(backup) => computeBackupType(backup, isHassio) === type
);
return [type, computeBackupStats(backupsOfType)] as const;
})
);
private _automaticStats = memoizeOne((backups: BackupContent[]) => {
const automaticBackups = backups.filter(
(backup) => backup.with_automatic_settings
);
return computeBackupStats(automaticBackups);
});
private _manualStats = memoizeOne((backups: BackupContent[]) => {
const manualBackups = backups.filter(
(backup) => !backup.with_automatic_settings
);
return computeBackupStats(manualBackups);
});
render() {
const isHassio = isComponentLoaded(this.hass, "hassio");
const stats = this._stats(this.backups, isHassio);
const automaticStats = this._automaticStats(this.backups);
const manualStats = this._manualStats(this.backups);
return html`
<ha-card class="my-backups">
@@ -70,32 +62,44 @@ class HaBackupOverviewBackups extends LitElement {
</div>
<div class="card-content">
<ha-md-list>
${stats.map(
([type, { count, size }]) => html`
<ha-md-list-item
type="link"
href="/config/backup/backups?type=${type}"
>
<ha-svg-icon
slot="start"
.path=${TYPE_ICONS[type]}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.backup.overview.backups.${type}`,
{ count }
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.total_size",
{ size: bytesToString(size) }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`
)}
<ha-md-list-item
type="link"
href="/config/backup/backups?type=automatic"
>
<ha-svg-icon slot="start" .path=${mdiCalendarSync}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.automatic",
{ count: automaticStats.count }
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.total_size",
{ size: bytesToString(automaticStats.size, 1) }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item
type="link"
href="/config/backup/backups?type=manual"
>
<ha-svg-icon slot="start" .path=${mdiGestureTap}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.manual",
{ count: manualStats.count }
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.total_size",
{ size: bytesToString(manualStats.size, 1) }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
<div class="card-actions">
@@ -1,225 +0,0 @@
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;
}
}
@@ -1,21 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { BackupContent } from "../../../../data/backup";
export interface DownloadDecryptedBackupDialogParams {
backup: BackupContent;
agentId?: string;
}
export const loadDownloadDecryptedBackupDialog = () =>
import("./dialog-download-decrypted-backup");
export const showDownloadDecryptedBackupDialog = (
element: HTMLElement,
params: DownloadDecryptedBackupDialogParams
) => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-download-decrypted-backup",
dialogImport: loadDownloadDecryptedBackupDialog,
dialogParams: params,
});
};
@@ -11,7 +11,6 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
@@ -43,11 +42,9 @@ import {
compareAgents,
computeBackupAgentName,
computeBackupSize,
computeBackupType,
deleteBackup,
generateBackup,
generateBackupWithAutomaticSettings,
getBackupTypes,
isLocalAgent,
isNetworkMountAgent,
} from "../../../data/backup";
@@ -77,6 +74,10 @@ interface BackupRow extends DataTableRowData, BackupContent {
agent_ids: string[];
}
type BackupType = "automatic" | "manual";
const TYPE_ORDER: BackupType[] = ["automatic", "manual"];
@customElement("ha-config-backup-backups")
class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -140,10 +141,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
};
private _columns = memoizeOne(
(
localize: LocalizeFunc,
maxDisplayedAgents: number
): DataTableColumnContainer<BackupRow> => ({
(localize: LocalizeFunc): DataTableColumnContainer<BackupRow> => ({
name: {
title: localize("ui.panel.config.backup.name"),
main: true,
@@ -174,75 +172,54 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
locations: {
title: localize("ui.panel.config.backup.locations"),
showNarrow: true,
// 24 icon size, 4 gap, 16 left and right padding
minWidth: `${maxDisplayedAgents * 24 + (maxDisplayedAgents - 1) * 4 + 32}px`,
template: (backup) => {
const agentIds = backup.agent_ids;
const displayedAgentIds =
agentIds.length > maxDisplayedAgents
? [...agentIds].splice(0, maxDisplayedAgents - 1)
: agentIds;
const agentsMore = Math.max(
agentIds.length - displayedAgentIds.length,
0
);
return html`
<div style="display: flex; gap: 4px;">
${displayedAgentIds.map((agentId) => {
const name = computeBackupAgentName(
this.hass.localize,
agentId,
this.agents
);
if (isLocalAgent(agentId)) {
return html`
<ha-svg-icon
.path=${mdiHarddisk}
title=${name}
style="flex-shrink: 0;"
></ha-svg-icon>
`;
}
if (isNetworkMountAgent(agentId)) {
return html`
<ha-svg-icon
.path=${mdiNas}
title=${name}
style="flex-shrink: 0;"
></ha-svg-icon>
`;
}
const domain = computeDomain(agentId);
minWidth: "60px",
template: (backup) => html`
<div style="display: flex; gap: 4px;">
${(backup.agent_ids || []).map((agentId) => {
const name = computeBackupAgentName(
this.hass.localize,
agentId,
this.agents
);
if (isLocalAgent(agentId)) {
return html`
<img
<ha-svg-icon
.path=${mdiHarddisk}
title=${name}
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
height="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${name}
slot="graphic"
style="flex-shrink: 0;"
/>
></ha-svg-icon>
`;
})}
${agentsMore
? html`
<span
style="display: flex; align-items: center; font-size: 14px;"
>
+${agentsMore}
</span>
`
: nothing}
</div>
`;
},
}
if (isNetworkMountAgent(agentId)) {
return html`
<ha-svg-icon
.path=${mdiNas}
title=${name}
style="flex-shrink: 0;"
></ha-svg-icon>
`;
}
const domain = computeDomain(agentId);
return html`
<img
title=${name}
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
height="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${name}
slot="graphic"
style="flex-shrink: 0;"
/>
`;
})}
</div>
`,
},
actions: {
title: "",
@@ -276,13 +253,9 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
);
private _groupOrder = memoizeOne(
(
activeGrouping: string | undefined,
localize: LocalizeFunc,
isHassio: boolean
) =>
(activeGrouping: string | undefined, localize: LocalizeFunc) =>
activeGrouping === "formatted_type"
? getBackupTypes(isHassio).map((type) =>
? TYPE_ORDER.map((type) =>
localize(`ui.panel.config.backup.type.${type}`)
)
: undefined
@@ -306,48 +279,33 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
(
backups: BackupContent[],
filters: DataTableFiltersValues,
localize: LocalizeFunc,
isHassio: boolean
localize: LocalizeFunc
): BackupRow[] => {
const typeFilter = filters["ha-filter-states"] as string[] | undefined;
let filteredBackups = backups;
if (typeFilter?.length) {
filteredBackups = filteredBackups.filter((backup) => {
const type = computeBackupType(backup, isHassio);
return typeFilter.includes(type);
});
filteredBackups = filteredBackups.filter(
(backup) =>
(backup.with_automatic_settings &&
typeFilter.includes("automatic")) ||
(!backup.with_automatic_settings && typeFilter.includes("manual"))
);
}
return filteredBackups.map((backup) => {
const type = computeBackupType(backup, isHassio);
const agentIds = Object.keys(backup.agents);
const type = backup.with_automatic_settings ? "automatic" : "manual";
return {
...backup,
size: computeBackupSize(backup),
agent_ids: agentIds.sort(compareAgents),
agent_ids: Object.keys(backup.agents).sort(compareAgents),
formatted_type: localize(`ui.panel.config.backup.type.${type}`),
};
});
}
);
private _maxAgents = memoizeOne((data: BackupRow[]): number =>
Math.max(...data.map((row) => row.agent_ids.length))
);
protected render(): TemplateResult {
const backupInProgress =
"state" in this.manager && this.manager.state === "in_progress";
const isHassio = isComponentLoaded(this.hass, "hassio");
const data = this._data(
this.backups,
this._filters,
this.hass.localize,
isHassio
);
const maxDisplayedAgents = Math.min(
this._maxAgents(data),
this.narrow ? 3 : 5
);
return html`
<hass-tabs-subpage-data-table
@@ -378,16 +336,15 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
.initialCollapsedGroups=${this._activeCollapsed}
.groupOrder=${this._groupOrder(
this._activeGrouping,
this.hass.localize,
isHassio
this.hass.localize
)}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@selection-changed=${this._handleSelectionChanged}
.route=${this.route}
@row-click=${this._showBackupDetails}
.columns=${this._columns(this.hass.localize, maxDisplayedAgents)}
.data=${data}
.columns=${this._columns(this.hass.localize)}
.data=${this._data(this.backups, this._filters, this.hass.localize)}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
.searchLabel=${this.hass.localize(
"ui.panel.config.backup.picker.search"
@@ -443,7 +400,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.label=${this.hass.localize("ui.panel.config.backup.backup_type")}
.value=${this._filters["ha-filter-states"]}
.states=${this._states(this.hass.localize, isHassio)}
.states=${this._states(this.hass.localize)}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
expanded
@@ -468,8 +425,8 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
`;
}
private _states = memoizeOne((localize: LocalizeFunc, isHassio: boolean) =>
getBackupTypes(isHassio).map((type) => ({
private _states = memoizeOne((localize: LocalizeFunc) =>
TYPE_ORDER.map((type) => ({
value: type,
label: localize(`ui.panel.config.backup.type.${type}`),
}))
@@ -539,7 +496,12 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
private async _downloadBackup(backup: BackupContent): Promise<void> {
downloadBackup(this.hass, this, backup, this.config);
downloadBackup(
this.hass,
this,
backup,
this.config?.create_backup.password
);
}
private async _deleteBackup(backup: BackupContent): Promise<void> {
@@ -31,7 +31,6 @@ import {
compareAgents,
computeBackupAgentName,
computeBackupSize,
computeBackupType,
deleteBackup,
fetchBackupDetails,
isLocalAgent,
@@ -47,7 +46,6 @@ import { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup";
import { fireEvent } from "../../../common/dom/fire_event";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { downloadBackup } from "./helper/download_backup";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
interface Agent extends BackupContentAgent {
id: string;
@@ -112,8 +110,6 @@ class HaConfigBackupDetails extends LitElement {
return nothing;
}
const isHassio = isComponentLoaded(this.hass, "hassio");
return html`
<hass-subpage
back-path="/config/backup/backups"
@@ -165,18 +161,6 @@ class HaConfigBackupDetails extends LitElement {
</div>
<div class="card-content">
<ha-md-list class="summary">
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.backup_type"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.type.${computeBackupType(this._backup, isHassio)}`
)}
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
@@ -417,7 +401,13 @@ class HaConfigBackupDetails extends LitElement {
}
private async _downloadBackup(agentId?: string): Promise<void> {
await downloadBackup(this.hass, this, this._backup!, this.config, agentId);
await downloadBackup(
this.hass,
this,
this._backup!,
this.config?.create_backup.password,
agentId
);
}
private async _deleteBackup(): Promise<void> {
@@ -221,7 +221,8 @@ class HaConfigBackupOverview extends LitElement {
gap: 24px;
display: flex;
flex-direction: column;
margin-bottom: calc(env(safe-area-inset-bottom) + 72px);
margin-bottom: 24px;
margin-bottom: 72px;
}
.card-actions {
display: flex;
@@ -50,11 +50,9 @@ class HaConfigBackupSettings extends LitElement {
}
}
public connectedCallback(): void {
super.connectedCallback();
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
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() {
@@ -119,7 +119,6 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
settings: {
tag: "ha-config-backup-settings",
load: () => import("./ha-config-backup-settings"),
cache: true,
},
location: {
tag: "ha-config-backup-location",
@@ -1,17 +1,20 @@
import type { LitElement } from "lit";
import { getSignedPath } from "../../../../data/auth";
import type { BackupConfig, BackupContent } from "../../../../data/backup";
import {
canDecryptBackupOnDownload,
getBackupDownloadUrl,
getPreferredAgentForDownload,
type BackupContent,
} from "../../../../data/backup";
import type { HomeAssistant } from "../../../../types";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../lovelace/custom-card-helpers";
import { getSignedPath } from "../../../../data/auth";
import { fileDownload } from "../../../../util/file_download";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
import { showDownloadDecryptedBackupDialog } from "../dialogs/show-dialog-download-decrypted-backup";
export const downloadBackupFile = async (
const triggerDownload = async (
hass: HomeAssistant,
backupId: string,
preferedAgent: string,
@@ -24,80 +27,120 @@ export const downloadBackupFile = async (
fileDownload(signedUrl.path);
};
const downloadEncryptedBackup = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
agentId?: string
) => {
if (
await showConfirmationDialog(element, {
title: "Encryption key incorrect",
text: hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_entered_encryption_key"
),
confirmText: "Download encrypted",
})
) {
const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
triggerDownload(hass, backup.backup_id, preferedAgent);
}
};
const requestEncryptionKey = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
agentId?: string
): Promise<void> => {
const encryptionKey = await showPromptDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_current_encryption_key"
),
inputLabel: hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
),
inputType: "password",
confirmText: hass.localize("ui.common.download"),
});
if (encryptionKey === null) {
return;
}
downloadBackup(hass, element, backup, encryptionKey, agentId, true);
};
export const downloadBackup = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
backupConfig?: BackupConfig,
agentId?: string
encryptionKey?: string | null,
agentId?: string,
userProvided = false
): Promise<void> => {
const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
const isProtected = backup.agents[preferedAgent]?.protected;
if (!isProtected) {
downloadBackupFile(hass, backup.backup_id, preferedAgent);
return;
}
if (isProtected) {
if (encryptionKey) {
try {
await canDecryptBackupOnDownload(
hass,
backup.backup_id,
preferedAgent,
encryptionKey
);
} catch (err: any) {
if (err?.code === "password_incorrect") {
if (userProvided) {
downloadEncryptedBackup(hass, element, backup, agentId);
} else {
requestEncryptionKey(hass, element, backup, agentId);
}
return;
}
if (err?.code === "decrypt_not_supported") {
showAlertDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_unsupported_title"
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_unsupported"
),
confirm() {
triggerDownload(hass, backup.backup_id, preferedAgent);
},
});
encryptionKey = undefined;
return;
}
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,
});
showAlertDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_title",
{
error: err.message,
}
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_description",
{
error: err.message,
}
),
});
return;
}
} else {
requestEncryptionKey(hass, element, backup, agentId);
return;
}
// If 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,
}
),
});
}
await triggerDownload(hass, backup.backup_id, preferedAgent, encryptionKey);
};
@@ -1,15 +1,15 @@
import "@material/mwc-button";
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js";
import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { fireEvent } from "../../../../common/dom/fire_event";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-card";
import "../../../../components/ha-list-item";
import "../../../../components/ha-tip";
import "../../../../components/ha-list-item";
import "../../../../components/ha-button-menu";
import type {
CloudStatusLoggedIn,
SubscriptionInfo,
@@ -32,7 +32,6 @@ import "./cloud-ice-servers-pref";
import "./cloud-remote-pref";
import "./cloud-tts-pref";
import "./cloud-webhooks";
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
@customElement("cloud-account")
export class CloudAccount extends SubscribeMixin(LitElement) {
@@ -53,7 +52,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
header="Home Assistant Cloud"
>
<ha-button-menu slot="toolbar-icon" @action=${this._handleMenuAction}>
<ha-button-menu slot="toolbar-icon" @action=${this._deleteCloudData}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
@@ -66,12 +65,6 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
)}
<ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
<ha-svg-icon slot="graphic" .path=${mdiDownload}></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
@@ -293,16 +286,6 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
fireEvent(this, "ha-refresh-cloud-status");
}
private _handleMenuAction(ev) {
switch (ev.detail.index) {
case 0:
this._deleteCloudData();
break;
case 1:
this._downloadSupportPackage();
}
}
private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
@@ -333,10 +316,6 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
}
}
private async _downloadSupportPackage() {
showSupportPackageDialog(this);
}
static get styles() {
return [
haStyle,
@@ -1,206 +0,0 @@
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;
}
}
@@ -1,12 +0,0 @@
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: {},
});
};
+2 -23
View File
@@ -1,6 +1,6 @@
import "@material/mwc-button";
import "@material/mwc-list/mwc-list";
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -27,7 +27,6 @@ import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
@customElement("cloud-login")
export class CloudLogin extends LitElement {
@@ -58,7 +57,7 @@ export class CloudLogin extends LitElement {
.narrow=${this.narrow}
header="Home Assistant Cloud"
>
<ha-button-menu slot="toolbar-icon" @action=${this._handleMenuAction}>
<ha-button-menu slot="toolbar-icon" @action=${this._deleteCloudData}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
@@ -71,12 +70,6 @@ export class CloudLogin extends LitElement {
)}
<ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
<ha-svg-icon slot="graphic" .path=${mdiDownload}></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
@@ -355,16 +348,6 @@ export class CloudLogin extends LitElement {
fireEvent(this, "flash-message-changed", { value: "" });
}
private _handleMenuAction(ev) {
switch (ev.detail.index) {
case 0:
this._deleteCloudData();
break;
case 1:
this._downloadSupportPackage();
}
}
private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
@@ -394,10 +377,6 @@ export class CloudLogin extends LitElement {
}
}
private async _downloadSupportPackage() {
showSupportPackageDialog(this);
}
static get styles() {
return [
haStyle,
@@ -66,18 +66,6 @@ const randomTip = (hass: HomeAssistant, narrow: boolean) => {
rel="noreferrer"
>${hass.localize("ui.panel.config.tips.join_x")}</a
>`,
mastodon: html`<a
href=${documentationUrl(hass, `/mastodon`)}
target="_blank"
rel="noreferrer"
>${hass.localize("ui.panel.config.tips.join_mastodon")}</a
>`,
bluesky: html`<a
href=${documentationUrl(hass, `/bluesky`)}
target="_blank"
rel="noreferrer"
>${hass.localize("ui.panel.config.tips.join_bluesky")}</a
>`,
discord: html`<a
href=${documentationUrl(hass, `/join-chat`)}
target="_blank"
@@ -1073,14 +1073,7 @@ export class HaConfigDevicePage extends LitElement {
(ent) => computeDomain(ent.entity_id) === "assist_satellite"
);
const domains = this._integrations(
device,
this.entries,
this.manifests
).map((int) => int.domain);
if (
!domains.includes("voip") &&
assistSatellite &&
assistSatelliteSupportsSetupFlow(
this.hass.states[assistSatellite.entity_id]
@@ -1095,6 +1088,12 @@ export class HaConfigDevicePage extends LitElement {
});
}
const domains = this._integrations(
device,
this.entries,
this.manifests
).map((int) => int.domain);
if (domains.includes("mqtt")) {
const mqtt = await import(
"./device-detail/integration-elements/mqtt/device-actions"
@@ -39,6 +39,7 @@ import { hardwareBrandsUrl } from "../../../util/brands-url";
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { ECOption } from "../../../resources/echarts";
import { getTimeAxisLabelConfig } from "../../../components/chart/axis-label";
const DATASAMPLES = 60;
@@ -152,6 +153,13 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
this._chartOptions = {
xAxis: {
type: "time",
axisLabel: getTimeAxisLabelConfig(this.hass.locale, this.hass.config),
splitLine: {
show: true,
},
axisLine: {
show: false,
},
},
yAxis: {
type: "value",
@@ -70,7 +70,7 @@ export class HaConfigFlowCard extends LitElement {
? html`<a
href=${this.flow.context.configuration_url.replace(
/^homeassistant:\/\//,
"/"
""
)}
rel="noreferrer"
target=${this.flow.context.configuration_url.startsWith(
@@ -1,9 +1,8 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../../../common/decorators/storage";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type {
@@ -12,6 +11,9 @@ import type {
} from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-fab";
import "../../../../../components/ha-icon-button";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import type {
BluetoothDeviceData,
BluetoothScannersDetails,
@@ -20,10 +22,6 @@ import {
subscribeBluetoothAdvertisements,
subscribeBluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info";
@customElement("bluetooth-advertisement-monitor")
@@ -40,22 +38,6 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
@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_scanners?: UnsubscribeFunc;
@@ -75,19 +57,6 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
this._scanners = scanners;
}
);
const devices = Object.values(this.hass.devices);
const bluetoothDevices = devices.filter((device) =>
device.connections.find((connection) => connection[0] === "bluetooth")
);
this._sourceDevices = Object.fromEntries(
bluetoothDevices.map((device) => {
const connection = device.connections.find(
(c) => c[0] === "bluetooth"
)!;
return [connection[1], device];
})
);
}
}
@@ -122,28 +91,14 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
filterable: true,
sortable: true,
},
device: {
title: localize("ui.panel.config.bluetooth.device"),
filterable: true,
sortable: true,
template: (data) => html`${data.device || "-"}`,
},
source: {
title: localize("ui.panel.config.bluetooth.source"),
filterable: true,
sortable: true,
groupable: true,
},
source_address: {
title: localize("ui.panel.config.bluetooth.source_address"),
filterable: true,
sortable: true,
defaultHidden: true,
},
rssi: {
title: localize("ui.panel.config.bluetooth.rssi"),
type: "numeric",
maxWidth: "60px",
sortable: true,
},
};
@@ -153,22 +108,11 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
);
private _dataWithNamedSourceAndIds = memoizeOne((data) =>
data.map((row) => {
const device = this._sourceDevices[row.address];
const scannerDevice = this._sourceDevices[row.source];
const scanner = this._scanners[row.source];
return {
...row,
id: row.address,
source_address: row.source,
source:
scannerDevice?.name_by_user ||
scannerDevice?.name ||
scanner?.name ||
row.source,
device: device?.name_by_user || device?.name || undefined,
};
})
data.map((row) => ({
...row,
id: row.address,
source: this._scanners[row.source]?.name || row.source,
}))
);
protected render(): TemplateResult {
@@ -180,23 +124,11 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
.columns=${this._columns(this.hass.localize)}
.data=${this._dataWithNamedSourceAndIds(this._data)}
@row-click=${this._handleRowClicked}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
clickable
></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>) {
const entry = this._data.find((ent) => ent.address === ev.detail.id);
showBluetoothDeviceInfoDialog(this, {
@@ -53,6 +53,8 @@ class DialogBluetoothDeviceInfo extends LitElement implements HassDialog {
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
@@ -76,8 +76,6 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
@state()
private _statistics?: ZWaveJSControllerStatisticsUpdatedMessage;
private _dialogOpen = false;
protected async firstUpdated() {
if (this.hass) {
await this._fetchData();
@@ -106,17 +104,11 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
}
),
subscribeS2Inclusion(this.hass, this.configEntryId, (message) => {
if (!this._dialogOpen) {
showZWaveJSAddNodeDialog(this, {
entry_id: this.configEntryId,
dsk: message.dsk,
onStop: () => {
setTimeout(() => this._fetchData(), 100);
this._dialogOpen = false;
},
});
this._dialogOpen = true;
}
showZWaveJSAddNodeDialog(this, {
entry_id: this.configEntryId,
dsk: message.dsk,
onStop: () => setTimeout(() => this._fetchData(), 100),
});
}),
];
}
@@ -578,17 +570,11 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
}
private async _addNodeClicked() {
if (!this._dialogOpen) {
showZWaveJSAddNodeDialog(this, {
entry_id: this.configEntryId!,
// refresh the data after the dialog is closed. add a small delay for the inclusion state to update
onStop: () => {
setTimeout(() => this._fetchData(), 100);
this._dialogOpen = false;
},
});
this._dialogOpen = true;
}
showZWaveJSAddNodeDialog(this, {
entry_id: this.configEntryId!,
// refresh the data after the dialog is closed. add a small delay for the inclusion state to update
onStop: () => setTimeout(() => this._fetchData(), 100),
});
}
private async _removeNodeClicked() {
+2 -2
View File
@@ -25,7 +25,7 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time";
import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -304,7 +304,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
return html`
${script.last_triggered
? dayDifference > 3
? formatShortDateTimeWithConditionalYear(
? formatShortDateTime(
date,
this.hass.locale,
this.hass.config
+3 -3
View File
@@ -173,7 +173,7 @@ class HaPanelHistory extends LitElement {
.endDate=${this._endDate}
extended-presets
time-picker
@value-changed=${this._dateRangeChanged}
@change=${this._dateRangeChanged}
></ha-date-range-picker>
<ha-target-picker
.hass=${this.hass}
@@ -424,8 +424,8 @@ class HaPanelHistory extends LitElement {
);
private _dateRangeChanged(ev) {
this._startDate = ev.detail.value.startDate;
const endDate = ev.detail.value.endDate;
this._startDate = ev.detail.startDate;
const endDate = ev.detail.endDate;
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
endDate.setDate(endDate.getDate() + 1);
endDate.setMilliseconds(endDate.getMilliseconds() - 1);
+3 -3
View File
@@ -93,7 +93,7 @@ export class HaPanelLogbook extends LitElement {
.hass=${this.hass}
.startDate=${this._time.range[0]}
.endDate=${this._time.range[1]}
@value-changed=${this._dateRangeChanged}
@change=${this._dateRangeChanged}
time-picker
></ha-date-range-picker>
@@ -233,8 +233,8 @@ export class HaPanelLogbook extends LitElement {
}
private _dateRangeChanged(ev) {
const startDate = ev.detail.value.startDate;
const endDate = ev.detail.value.endDate;
const startDate = ev.detail.startDate;
const endDate = ev.detail.endDate;
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
endDate.setDate(endDate.getDate() + 1);
endDate.setMilliseconds(endDate.getMilliseconds() - 1);
@@ -1,16 +1,5 @@
import type { HassConfig } from "home-assistant-js-websocket";
import {
differenceInMonths,
subHours,
differenceInDays,
differenceInYears,
startOfYear,
addMilliseconds,
startOfMonth,
addYears,
addMonths,
addHours,
} from "date-fns";
import { addHours, subHours, differenceInDays } from "date-fns";
import type {
BarSeriesOption,
CallbackDataParams,
@@ -18,12 +7,10 @@ import type {
} from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../../../../data/translation";
import { formatNumber } from "../../../../../common/number/format_number";
import {
formatDateMonthYear,
formatDateVeryShort,
} from "../../../../../common/datetime/format_date";
import { formatDateVeryShort } from "../../../../../common/datetime/format_date";
import { formatTime } from "../../../../../common/datetime/format_time";
import type { ECOption } from "../../../../../resources/echarts";
import { getTimeAxisLabelConfig } from "../../../../../components/chart/axis-label";
export function getSuggestedMax(dayDifference: number, end: Date): number {
let suggestedMax = new Date(end);
@@ -65,17 +52,28 @@ export function getCommonOptions(
const options: ECOption = {
xAxis: {
id: "xAxisMain",
type: "time",
min: start,
min: start.getTime(),
max: getSuggestedMax(dayDifference, end),
axisLabel: getTimeAxisLabelConfig(locale, config, dayDifference),
axisLine: {
show: false,
},
splitLine: {
show: true,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: "value",
name: unit,
nameGap: 2,
nameTextStyle: {
align: "left",
},
nameGap: 5,
axisLabel: {
formatter: (value: number) => formatNumber(Math.abs(value), locale),
},
@@ -84,10 +82,10 @@ export function getCommonOptions(
},
},
grid: {
top: 15,
bottom: 0,
left: 1,
right: 1,
top: 35,
bottom: 10,
left: 10,
right: 10,
containLabel: true,
},
tooltip: {
@@ -105,6 +103,7 @@ export function getCommonOptions(
}
});
return [mainItems, compareItems]
.filter((items) => items.length > 0)
.map((items) =>
formatTooltip(
items,
@@ -116,7 +115,6 @@ export function getCommonOptions(
formatTotal
)
)
.filter(Boolean)
.join("<br><br>");
}
return formatTooltip(
@@ -143,16 +141,14 @@ function formatTooltip(
unit?: string,
formatTotal?: (total: number) => string
) {
if (!params[0]?.value) {
if (!params[0].value) {
return "";
}
// when comparing the first value is offset to match the main period
// and the real date is in the third value
const date = new Date(params[0].value?.[2] ?? params[0].value?.[0]);
let period: string;
if (dayDifference > 89) {
period = `${formatDateMonthYear(date, locale, config)}`;
} else if (dayDifference > 0) {
if (dayDifference > 0) {
period = `${formatDateVeryShort(date, locale, config)}`;
} else {
period = `${
@@ -202,9 +198,7 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
const buckets = Array.from(
new Set(
datasets
.map((dataset) =>
dataset.data!.map((datapoint) => Number(datapoint![0]))
)
.map((dataset) => dataset.data!.map((datapoint) => datapoint![0]))
.flat()
)
).sort((a, b) => a - b);
@@ -225,7 +219,7 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
if (x === undefined) {
continue;
}
if (Number(x) !== bucket) {
if (x !== bucket) {
datasets[i].data?.splice(index, 0, {
value: [bucket, 0],
itemStyle: {
@@ -263,25 +257,3 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
}
});
}
export function getCompareTransform(start: Date, compareStart?: Date) {
if (!compareStart) {
return (ts: Date) => ts;
}
const compareYearDiff = differenceInYears(start, compareStart);
if (
compareYearDiff !== 0 &&
start.getTime() === startOfYear(start).getTime()
) {
return (ts: Date) => addYears(ts, compareYearDiff);
}
const compareMonthDiff = differenceInMonths(start, compareStart);
if (
compareMonthDiff !== 0 &&
start.getTime() === startOfMonth(start).getTime()
) {
return (ts: Date) => addMonths(ts, compareMonthDiff);
}
const compareOffset = start.getTime() - compareStart.getTime();
return (ts: Date) => addMilliseconds(ts, compareOffset);
}
@@ -33,7 +33,6 @@ import { hasConfigChanged } from "../../common/has-changed";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import { storage } from "../../../../common/decorators/storage";
import type { ECOption } from "../../../../resources/echarts";
@@ -193,10 +192,9 @@ export class HuiEnergyDevicesDetailGraphCard
icon: "circle",
},
grid: {
top: 45,
bottom: 0,
left: 1,
right: 1,
left: 5,
right: 5,
containLabel: true,
},
};
@@ -316,34 +314,29 @@ export class HuiEnergyDevicesDetailGraphCard
processedData.forEach((device) => {
device.data.forEach((datapoint) => {
totalDeviceConsumption[datapoint[compare ? 2 : 0]] =
(totalDeviceConsumption[datapoint[compare ? 2 : 0]] || 0) +
datapoint[1];
totalDeviceConsumption[datapoint[0]] =
(totalDeviceConsumption[datapoint[0]] || 0) + datapoint[1];
});
});
const compareTransform = getCompareTransform(
this._start,
this._compareStart!
);
const compareOffset = compare
? this._start.getTime() - this._compareStart!.getTime()
: 0;
const untrackedConsumption: BarSeriesOption["data"] = [];
Object.keys(consumptionData.total).forEach((time) => {
const ts = Number(time);
const value =
consumptionData.total[time] - (totalDeviceConsumption[time] || 0);
const dataPoint: number[] = [ts, value];
const dataPoint = [Number(time), value];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(ts)).getTime();
dataPoint[0] += compareOffset;
}
untrackedConsumption.push(dataPoint);
});
// random id to always add untracked at the end
const order = Date.now();
const dataset: BarSeriesOption = {
type: "bar",
cursor: "default",
id: compare ? `compare-untracked-${order}` : `untracked-${order}`,
id: compare ? "compare-untracked" : "untracked",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
@@ -379,10 +372,9 @@ export class HuiEnergyDevicesDetailGraphCard
compare = false
) {
const data: BarSeriesOption[] = [];
const compareTransform = getCompareTransform(
this._start,
this._compareStart!
);
const compareOffset = compare
? this._start.getTime() - this._compareStart!.getTime()
: 0;
devices.forEach((source, idx) => {
const order = sorted_devices.indexOf(source.stat_consumption);
@@ -417,7 +409,7 @@ export class HuiEnergyDevicesDetailGraphCard
const dataPoint = [point.start, point.change];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(point.start)).getTime();
dataPoint[0] += compareOffset;
}
consumptionData.push(dataPoint);
prevStart = point.start;
@@ -427,10 +419,9 @@ export class HuiEnergyDevicesDetailGraphCard
data.push({
type: "bar",
cursor: "default",
// add order to id, otherwise echarts refuses to reorder them
id: compare
? `compare-${source.stat_consumption}-${order}`
: `${source.stat_consumption}-${order}`,
? `compare-${source.stat_consumption}`
: source.stat_consumption,
name:
source.name ||
getStatisticLabel(
@@ -447,17 +438,7 @@ export class HuiEnergyDevicesDetailGraphCard
stack: compare ? "devicesCompare" : "devices",
});
});
return sorted_devices
.map(
(device) =>
data.find((d) => {
const id = (d.id as string)
.replace(/^compare-/, "") // Remove compare- prefix
.replace(/-\d+$/, ""); // Remove numeric suffix
return id === device;
})!
)
.filter(Boolean);
return data;
}
static styles = css`
@@ -27,7 +27,6 @@ import { hasConfigChanged } from "../../common/has-changed";
import type { ECOption } from "../../../../resources/echarts";
import "../../../../components/ha-card";
import { fireEvent } from "../../../../common/dom/fire_event";
import { measureTextWidth } from "../../../../util/text";
@customElement("hui-energy-devices-graph-card")
export class HuiEnergyDevicesGraphCard
@@ -89,7 +88,7 @@ export class HuiEnergyDevicesGraphCard
<ha-chart-base
.hass=${this.hass}
.data=${this._chartData}
.options=${this._createOptions(this._chartData)}
.options=${this._createOptions(this.hass.themes?.darkMode)}
.height=${`${(this._chartData[0]?.data?.length || 0) * 28 + 50}px`}
@chart-click=${this._handleChartClick}
></ha-chart-base>
@@ -110,35 +109,22 @@ export class HuiEnergyDevicesGraphCard
return `${title}${params.marker} ${params.seriesName}: ${value}`;
}
private _createOptions = memoizeOne((data: BarSeriesOption[]): ECOption => {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
return {
private _createOptions = memoizeOne(
(darkMode: boolean): ECOption => ({
xAxis: {
type: "value",
name: "kWh",
splitLine: {
lineStyle: darkMode ? { opacity: 0.15 } : {},
},
},
yAxis: {
type: "category",
inverse: true,
triggerEvent: true,
// take order from data
data: data[0]?.data?.map((d: any) => d.value[1]),
axisLabel: {
formatter: this._getDeviceName.bind(this),
overflow: "truncate",
fontSize: 12,
margin: 5,
width: Math.min(
isMobile ? 100 : 200,
Math.max(
...(data[0]?.data?.map(
(d: any) =>
measureTextWidth(this._getDeviceName(d.value[1]), 12) + 5
) || [])
)
),
},
},
grid: {
@@ -152,8 +138,8 @@ export class HuiEnergyDevicesGraphCard
show: true,
formatter: this._renderTooltip.bind(this),
},
};
});
})
);
private _getDeviceName(statisticId: string): string {
return (
@@ -29,7 +29,6 @@ import { hasConfigChanged } from "../../common/has-changed";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
@@ -214,10 +213,9 @@ export class HuiEnergyGasGraphCard
compare = false
) {
const data: BarSeriesOption[] = [];
const compareTransform = getCompareTransform(
this._start,
this._compareStart!
);
const compareOffset = compare
? this._start.getTime() - this._compareStart!.getTime()
: 0;
gasSources.forEach((source, idx) => {
let prevStart: number | null = null;
@@ -238,13 +236,10 @@ export class HuiEnergyGasGraphCard
if (prevStart === point.start) {
continue;
}
const dataPoint: (Date | string | number)[] = [
point.start,
point.change,
];
const dataPoint = [point.start, point.change];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(point.start));
dataPoint[0] += compareOffset;
}
gasConsumptionData.push(dataPoint);
prevStart = point.start;
@@ -30,7 +30,6 @@ import { hasConfigChanged } from "../../common/has-changed";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
@@ -232,10 +231,9 @@ export class HuiEnergySolarGraphCard
compare = false
) {
const data: BarSeriesOption[] = [];
const compareTransform = getCompareTransform(
this._start,
this._compareStart!
);
const compareOffset = compare
? this._start.getTime() - this._compareStart!.getTime()
: 0;
solarSources.forEach((source, idx) => {
let prevStart: number | null = null;
@@ -257,13 +255,10 @@ export class HuiEnergySolarGraphCard
if (prevStart === point.start) {
continue;
}
const dataPoint: (Date | string | number)[] = [
point.start,
point.change,
];
const dataPoint = [point.start, point.change];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(point.start));
dataPoint[0] += compareOffset;
}
solarProductionData.push(dataPoint);
prevStart = point.start;
@@ -367,7 +362,6 @@ export class HuiEnergySolarGraphCard
data.push({
id: "forecast-" + source.stat_energy_from,
type: "line",
stack: "forecast",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.forecast",
{
@@ -27,7 +27,6 @@ import { hasConfigChanged } from "../../common/has-changed";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
@@ -300,8 +299,6 @@ export class HuiEnergyUsageGraphCard
type: "bar",
stack: "usage",
data: [],
// @ts-expect-error
order: 0,
});
}
@@ -317,8 +314,6 @@ export class HuiEnergyUsageGraphCard
)
);
// @ts-expect-error
datasets.sort((a, b) => a.order - b.order);
fillDataGapsAndRoundCaps(datasets);
this._chartData = datasets;
}
@@ -481,12 +476,11 @@ export class HuiEnergyUsageGraphCard
(a, b) => Number(a) - Number(b)
);
const compareTransform = getCompareTransform(
this._start,
this._compareStart!
);
const compareOffset = compare
? this._start.getTime() - this._compareStart!.getTime()
: 0;
Object.entries(combinedData).forEach(([type, sources], idx) => {
Object.entries(combinedData).forEach(([type, sources]) => {
Object.entries(sources).forEach(([statId, source]) => {
const points: BarSeriesOption["data"] = [];
// Process chart data.
@@ -500,7 +494,7 @@ export class HuiEnergyUsageGraphCard
];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(dataPoint[0]);
dataPoint[0] += compareOffset;
}
points.push(dataPoint);
}
@@ -517,13 +511,6 @@ export class HuiEnergyUsageGraphCard
statId,
statisticsMetaData[statId]
),
// @ts-expect-error
order:
type === "used_solar"
? 1
: type === "to_battery"
? Object.keys(combinedData).length
: idx + 2,
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
@@ -28,7 +28,6 @@ import { hasConfigChanged } from "../../common/has-changed";
import {
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts";
import { formatNumber } from "../../../../common/number/format_number";
@@ -212,10 +211,9 @@ export class HuiEnergyWaterGraphCard
compare = false
) {
const data: BarSeriesOption[] = [];
const compareTransform = getCompareTransform(
this._start,
this._compareStart!
);
const compareOffset = compare
? this._start.getTime() - this._compareStart!.getTime()
: 0;
waterSources.forEach((source, idx) => {
let prevStart: number | null = null;
@@ -236,13 +234,10 @@ export class HuiEnergyWaterGraphCard
if (prevStart === point.start) {
continue;
}
const dataPoint: (Date | string | number)[] = [
point.start,
point.change,
];
const dataPoint = [point.start, point.change];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(point.start));
dataPoint[0] += compareOffset;
}
waterConsumptionData.push(dataPoint);
prevStart = point.start;
@@ -65,7 +65,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
return {
columns: 12,
min_columns: 6,
min_rows: 2,
min_rows: this._config?.entities?.length || 1,
};
}
@@ -244,8 +244,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
})}`;
const columns = this._config.grid_options?.columns ?? 12;
const narrow = typeof columns === "number" && columns <= 12;
const hasFixedHeight = typeof this._config.grid_options?.rows === "number";
const narrow = Number.isNaN(columns) || Number(columns) < 12;
return html`
<ha-card>
@@ -260,7 +259,6 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
<div
class="content ${classMap({
"has-header": !!this._config.title,
"has-rows": !!this._config.grid_options?.rows,
})}"
>
${this._error
@@ -285,7 +283,9 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.minYAxis=${this._config.min_y_axis}
.maxYAxis=${this._config.max_y_axis}
.fitYData=${this._config.fit_y_data || false}
.height=${hasFixedHeight ? "100%" : undefined}
.height=${this._config.grid_options?.rows
? "100%"
: undefined}
.narrow=${narrow}
></state-history-charts>
`}
@@ -303,7 +303,6 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.card-header {
justify-content: space-between;
display: flex;
padding-bottom: 0;
}
.card-header ha-icon-next {
--mdc-icon-button-size: 24px;
@@ -311,7 +310,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
color: var(--primary-text-color);
}
.content {
padding: 0 16px 8px 16px;
padding: 16px;
flex: 1;
}
.has-header {
@@ -319,10 +318,6 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
}
state-history-charts {
height: 100%;
--timeline-top-margin: 16px;
}
.has-rows {
--chart-max-height: 100%;
}
`;
}
@@ -6,10 +6,7 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../components/ha-card";
import { getEnergyDataCollection } from "../../../data/energy";
import {
getSuggestedMax,
getSuggestedPeriod,
} from "./energy/common/energy-chart-options";
import { getSuggestedPeriod } from "./energy/common/energy-chart-options";
import type {
Statistics,
StatisticsMetaData,
@@ -258,13 +255,8 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
return nothing;
}
const hasFixedHeight = typeof this._config.grid_options?.rows === "number";
return html`
<ha-card>
${this._config.title
? html`<h1 class="card-header">${this._config.title}</h1>`
: nothing}
<ha-card .header=${this._config.title}>
<div
class="content ${classMap({
"has-header": !!this._config.title,
@@ -282,20 +274,11 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
.unit=${this._unit}
.minYAxis=${this._config.min_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}
.hideLegend=${this._config.hide_legend || false}
.logarithmicScale=${this._config.logarithmic_scale || false}
.daysToShow=${this._energyStart && this._energyEnd
? differenceInDays(this._energyEnd, this._energyStart)
: this._config.days_to_show || DEFAULT_DAYS_TO_SHOW}
.height=${hasFixedHeight ? "100%" : undefined}
.daysToShow=${this._config.days_to_show || DEFAULT_DAYS_TO_SHOW}
.height=${this._config.grid_options?.rows ? "100%" : undefined}
></statistics-chart>
</div>
</ha-card>
@@ -327,7 +310,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
);
const endDate = this._energyEnd;
try {
let unitClass: string | undefined | null;
let unitClass;
if (this._config!.unit && this._metadata) {
const metadata = Object.values(this._metadata).find(
(metaData) =>
@@ -375,12 +358,8 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
flex-direction: column;
height: 100%;
}
.card-header {
padding-bottom: 0;
}
.content {
padding: 16px;
padding-top: 0;
flex: 1;
}
.has-header {
@@ -246,7 +246,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
.startDate=${this._startDate}
.endDate=${this._endDate || new Date()}
.ranges=${this._ranges}
@value-changed=${this._dateRangeChanged}
@change=${this._dateRangeChanged}
minimal
></ha-date-range-picker>
</div>
@@ -346,7 +346,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
private _dateRangeChanged(ev) {
const weekStartsOn = firstWeekdayIndex(this.hass.locale);
this._startDate = calcDate(
ev.detail.value.startDate,
ev.detail.startDate,
startOfDay,
this.hass.locale,
this.hass.config,
@@ -355,7 +355,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
}
);
this._endDate = calcDate(
ev.detail.value.endDate,
ev.detail.endDate,
endOfDay,
this.hass.locale,
this.hass.config,
@@ -201,7 +201,9 @@ export class HuiGenericEntityRow extends LitElement {
padding-inline-end: 8px;
flex: 1 1 30%;
min-height: 40px;
align-content: center;
display: flex;
flex-direction: column;
justify-content: center;
}
.info,
.info > * {
@@ -236,7 +238,8 @@ export class HuiGenericEntityRow extends LitElement {
.value {
direction: ltr;
min-height: 40px;
align-content: center;
display: flex;
align-items: center;
}
`;
}
@@ -240,7 +240,6 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
.container.edit-mode {
padding: 8px;
border-radius: var(--ha-card-border-radius, 12px);
border-start-end-radius: 0;
border: 2px dashed var(--divider-color);
min-height: var(--row-height);
}
@@ -462,7 +462,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
}
private _openMoreInfo() {
if (this.entityId === BROWSER_PLAYER) {
if (this._browserPlayer) {
return;
}
fireEvent(this, "hass-more-info", { entityId: this.entityId });
+5 -2
View File
@@ -286,7 +286,10 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
clearInterval(this.__backendPingInterval);
this.__backendPingInterval = setInterval(() => {
if (this.hass?.connected) {
promiseTimeout(5000, this.hass?.connection.ping()).catch(() => {
// If the backend if busy, or the connection is latent,
// it can take more than 10 seconds for the ping to return.
// We give it a 15 second timeout to be safe.
promiseTimeout(15000, this.hass?.connection.ping()).catch(() => {
if (!this.hass?.connected) {
return;
}
@@ -296,7 +299,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this.hass?.connection.reconnect(true);
});
}
}, 10000);
}, 30000);
}
protected hassReconnected() {
+13 -3
View File
@@ -29,11 +29,21 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
return;
}
if (
(!__DEV__ &&
ev.message.includes("ResizeObserver loop limit exceeded")) ||
// !__DEV__ &&
ev.message.includes("ResizeObserver loop limit exceeded") ||
ev.message.includes(
"ResizeObserver loop completed with undelivered notifications"
)
) ||
(ev.error.stack.includes("echarts") &&
(ev.message.includes(
"Cannot read properties of undefined (reading 'hostedBy')"
) ||
ev.message.includes(
"Cannot read properties of undefined (reading 'scale')"
) ||
ev.message.includes(
"Cannot read properties of null (reading 'innerHTML')"
)))
) {
ev.preventDefault();
ev.stopImmediatePropagation();
+12 -30
View File
@@ -2223,8 +2223,7 @@
"backup_type": "Type",
"type": {
"manual": "Manual",
"automatic": "Automatic",
"addon_update": "Add-on update"
"automatic": "Automatic"
},
"locations": "Locations",
"create": {
@@ -2393,23 +2392,17 @@
"download": {
"decryption_unsupported_title": "Decryption unsupported",
"decryption_unsupported": "Decryption is not supported for this backup. The downloaded backup will remain encrypted and can't be opened. To restore it, you will need the encryption key.",
"incorrect_entered_encryption_key": "The entered encryption key was incorrect, try again or download the encrypted backup. The encrypted backup can't be opened. To restore it, you will need the encryption key.",
"download_encrypted": "Download encrypted",
"incorrect_current_encryption_key": "This backup is encrypted with a different encryption key than the current one, please enter the encryption key of this backup.",
"error_check_title": "Error checking backup",
"error_check_description": "An error occurred while checking the backup, please try again. Error message: {error}",
"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"
"error_check_description": "An error occurred while checking the backup, please try again. Error message: {error}"
}
},
"agents": {
"cloud_agent_description": "Note: It stores only the most recent backup, regardless of your retention settings, with a maximum size of 5 GB.",
"cloud_agent_no_subcription": "You currently do not have an active Home Assistant Cloud subscription.",
"network_mount_agent_description": "Network storage",
"unavailable_agents": "Unavailable locations",
"no_agents": "No locations configured",
"encryption_turned_off": "Encryption turned off",
"local_agent": "This system"
@@ -2567,7 +2560,6 @@
"title": "My backups",
"automatic": "{count} automatic {count, plural,\n one {backup}\n other {backups}\n}",
"manual": "{count} manual {count, plural,\n one {backup}\n other {backups}\n}",
"addon_update": "{count} add-on update {count, plural,\n one {backup}\n other {backups}\n}",
"total_size": "{size} in total",
"show_all": "Show all backups"
},
@@ -2686,19 +2678,19 @@
"encryption": {
"title": "Encryption",
"description": "All your backups are encrypted by default to keep your data private and secure.",
"location_encrypted": "Backups made to this location will be encrypted",
"location_unencrypted": "Backups made to this location will be unencrypted",
"location_encrypted_description": "Your data is private and secure by encrypting backups with your encryption key.",
"location_encrypted": "This location is encrypted",
"location_unencrypted": "This location is unencrypted",
"location_encrypted_description": "Your data private and secure by securing it with your encryption key.",
"location_encrypted_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_unencrypted_description": "Please keep your backups private and secure.",
"encryption_turn_on": "Turn on",
"encryption_turn_off": "Turn off",
"encryption_turn_off_confirm_title": "Turn encryption off?",
"encryption_turn_off_confirm_text": "After confirming, backups created will be unencrypted for this location. Please ensure your backups remain private and secure.",
"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_action": "Turn encryption off",
"warning_encryption_turn_off": "Encryption turned off",
"warning_encryption_turn_off_description": "Backups will be unencrypted."
"warning_encryption_turn_off_description": "All your next backups will not be encrypted."
}
}
},
@@ -4592,7 +4584,6 @@
"account_created": "Account created! Check your email for instructions on how to activate your account."
},
"account": {
"download_support_package": "Download support package",
"reset_cloud_data": "Reset cloud data",
"reset_data_confirm_title": "Reset cloud data?",
"reset_data_confirm_text": "This will reset all your cloud settings. This includes your remote connection, Google Assistant and Amazon Alexa integrations. This action cannot be undone.",
@@ -5339,8 +5330,6 @@
"name": "Name",
"source": "Source",
"rssi": "RSSI",
"source_address": "Source address",
"device": "Device",
"device_information": "Device information",
"advertisement_data": "Advertisement data",
"manufacturer_data": "Manufacturer data",
@@ -6052,10 +6041,8 @@
},
"tips": {
"tip": "Tip!",
"join": "Join the community on our {forums}, {mastodon}, {bluesky}, {twitter}, {discord}, {blog} or {newsletter}",
"join": "Join the community on our {forums}, {twitter}, {discord}, {blog} or {newsletter}",
"join_x": "X (formerly Twitter)",
"join_mastodon": "Mastodon",
"join_bluesky": "Bluesky",
"join_forums": "Forums",
"join_chat": "Chat",
"join_blog": "Blog",
@@ -6124,12 +6111,7 @@
},
"network_adapter": "Network adapter",
"network_adapter_info": "Configure which network adapters integrations will use. Currently this setting only affects multicast traffic. A restart is required for these settings to apply.",
"ip_information": "IP Information",
"adapter": {
"auto_configure": "Auto configure",
"detected": "Detected",
"adapter": "Adapter"
}
"ip_information": "IP Information"
},
"storage": {
"caption": "Storage",
+2 -8
View File
@@ -10,7 +10,7 @@ let textMeasureCanvas: HTMLCanvasElement | undefined;
export function measureTextWidth(
text: string,
fontSize: number,
fontFamily = "Roboto, Noto, sans-serif"
fontFamily = "sans-serif"
): number {
if (!textMeasureCanvas) {
textMeasureCanvas = document.createElement("canvas");
@@ -21,11 +21,5 @@ export function measureTextWidth(
}
context.font = `${fontSize}px ${fontFamily}`;
const textMetrics = context.measureText(text);
return Math.ceil(
Math.max(
textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft,
textMetrics.width
)
);
return Math.ceil(context.measureText(text).width);
}