Compare commits

..

1 Commits

Author SHA1 Message Date
Wendelin de487d788a Add convert-action 2026-05-27 17:34:38 +02:00
53 changed files with 2009 additions and 1512 deletions
+4 -4
View File
@@ -27,7 +27,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.29.7",
"@babel/runtime": "7.29.2",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.2",
"@codemirror/commands": "6.10.3",
@@ -126,10 +126,10 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.29.7",
"@babel/core": "7.29.0",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.7",
"@babel/preset-env": "7.29.7",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.5",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.61.0",

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+29 -82
View File
@@ -14,7 +14,6 @@ import type {
ECElementEvent,
LegendComponentOption,
LineSeriesOption,
TooltipOption,
XAXisOption,
YAXisOption,
} from "echarts/types/dist/shared";
@@ -30,59 +29,22 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type {
ECOption,
HaECOption,
HaECSeries,
HaECSeriesItem,
HaTooltipOption,
} from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
readonly unknown[]
>;
const toEChartsFormatter = (
fn: ReturnType<typeof wrapLitTooltipFormatter>
): NonNullable<TooltipOption["formatter"]> =>
fn as NonNullable<TooltipOption["formatter"]>;
const convertHaTooltipFormatter = (tooltip: HaTooltipOption): TooltipOption => {
const { formatter, ...rest } = tooltip;
const next: TooltipOption = { ...rest };
if (typeof formatter === "function") {
next.formatter = toEChartsFormatter(wrapLitTooltipFormatter(formatter));
} else if (formatter !== undefined) {
next.formatter = formatter;
}
return next;
};
const processSeriesTooltipFormatter = (s: HaECSeriesItem): RawSeriesOption => {
if (s.tooltip && typeof s.tooltip.formatter === "function") {
return {
...s,
tooltip: convertHaTooltipFormatter(s.tooltip),
} as RawSeriesOption;
}
return s as RawSeriesOption;
};
export type CustomLegendOption = ECOption["legend"] & {
type: "custom";
data?: {
@@ -104,9 +66,9 @@ export class HaChartBase extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: HaECSeries = [];
@property({ attribute: false }) public data: ECOption["series"] = [];
@property({ attribute: false }) public options?: HaECOption;
@property({ attribute: false }) public options?: ECOption;
@property({ type: String }) public height?: string;
@@ -652,7 +614,7 @@ export class HaChartBase extends LitElement {
// Return an array of all IDs associated with the legend item of the primaryId
private _getAllIdsFromLegend(
options: HaECOption | undefined,
options: ECOption | undefined,
primaryId: string
): string[] {
if (!options) return [primaryId];
@@ -672,7 +634,7 @@ export class HaChartBase extends LitElement {
// Parses the options structure and adds all ids of unselected legend items to hiddenDatasets.
// No known need to remove items at this time.
private _updateHiddenStatsFromOptions(options: HaECOption | undefined) {
private _updateHiddenStatsFromOptions(options: ECOption | undefined) {
if (!options) return;
const legend = ensureArray(this.options?.legend || [])[0] as
| LegendComponentOption
@@ -795,34 +757,22 @@ export class HaChartBase extends LitElement {
xAxis,
};
if (options.tooltip) {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
// back into the caller's options.tooltip reference (callers may cache the
// options object via memoizeOne, in which case in-place mutation would
// pollute that cache across chart instances).
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
next.triggerOn = "click";
}
return next;
};
const haTooltip = options.tooltip;
const processedTooltip = Array.isArray(haTooltip)
? haTooltip.map(processTooltip)
: processTooltip(haTooltip);
return {
...options,
tooltip: processedTooltip,
} as ECOption;
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
if (isMobile && options.tooltip) {
// mobile charts are full width so we need to confine the tooltip to the chart
const tooltips = Array.isArray(options.tooltip)
? options.tooltip
: [options.tooltip];
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
tooltip.triggerOn = "click";
});
options.tooltip = tooltips;
}
return options as ECOption;
return options;
}
private _createTheme(style: CSSStyleDeclaration) {
@@ -1010,12 +960,8 @@ export class HaChartBase extends LitElement {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
let result = {
...s,
data,
} as HaECSeriesItem;
if (data && s.type === "line") {
if ((s as LineSeriesOption).sampling === "minmax") {
if (s.sampling === "minmax") {
const minX = xAxis?.min
? xAxis.min instanceof Date
? xAxis.min.getTime()
@@ -1030,8 +976,8 @@ export class HaChartBase extends LitElement {
? xAxis.max
: undefined
: undefined;
result = {
...result,
return {
...s,
sampling: undefined,
data: downSampleLineData(
data as LineSeriesOption["data"],
@@ -1039,10 +985,11 @@ export class HaChartBase extends LitElement {
minX,
maxX
),
} as HaECSeriesItem;
};
}
}
return processSeriesTooltipFormatter(result);
const name = filterXSS(String(s.name ?? s.id ?? ""));
return { ...s, name, data };
});
return series as ECOption["series"];
}
@@ -1379,8 +1326,8 @@ export class HaChartBase extends LitElement {
}
private _compareCustomLegendOptions(
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
oldOptions: ECOption | undefined,
newOptions: ECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
@@ -1,41 +0,0 @@
import type { PropertyValues } from "lit";
import { css, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-chart-tooltip-marker")
class HaChartTooltipMarker extends LitElement {
@property() public color = "";
@property({ type: Boolean, reflect: true }) public rtl = false;
protected willUpdate(changed: PropertyValues) {
if (changed.has("color")) {
this.style.backgroundColor = this.color;
}
}
protected render() {
return nothing;
}
static styles = css`
:host {
display: inline-block;
margin-inline-end: 4px;
margin-inline-start: initial;
border-radius: 10px;
width: 10px;
height: 10px;
vertical-align: middle;
}
:host([rtl]) {
direction: rtl;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-chart-tooltip-marker": HaChartTooltipMarker;
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import type { PropertyValues, TemplateResult } from "lit";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
@@ -11,7 +11,7 @@ import type {
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";
@@ -78,7 +78,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public tooltipFormatter?: (
params: TopLevelFormatterParams
) => TemplateResult | typeof nothing | null;
) => string;
/**
* Optional callback that returns additional searchable strings for a node.
@@ -182,7 +182,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
}
private _createOptions = memoizeOne(
(categories?: NetworkData["categories"]): HaECOption => ({
(categories?: NetworkData["categories"]): ECOption => ({
tooltip: {
trigger: "item",
confine: true,
+6 -10
View File
@@ -11,10 +11,10 @@ import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { filterXSS } from "../../common/util/xss";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import { NODE_SIZE } from "../trace/hat-graph-const";
import "../ha-alert";
@@ -71,7 +71,7 @@ export class HaSankeyChart extends LitElement {
});
render() {
const options: HaECOption = {
const options = {
grid: {
top: 0,
bottom: 0,
@@ -83,7 +83,7 @@ export class HaSankeyChart extends LitElement {
formatter: this._renderTooltip,
appendTo: document.body,
},
};
} as ECOption;
return html`<ha-chart-base
.hass=${this.hass}
@@ -103,16 +103,12 @@ export class HaSankeyChart extends LitElement {
: data.value;
if (data.id) {
const node = this.data.nodes.find((n) => n.id === data.id);
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${node?.label ?? data.id}<br />${value}`;
return `${params.marker} ${filterXSS(node?.label ?? data.id)}<br>${value}`;
}
if (data.source && data.target) {
const source = this.data.nodes.find((n) => n.id === data.source);
const target = this.data.nodes.find((n) => n.id === data.target);
return html`${source?.label ?? data.source}
${target?.label ?? data.target}<br />${value}`;
return `${filterXSS(source?.label ?? data.source)} ${filterXSS(target?.label ?? data.target)}<br>${value}`;
}
return null;
};
+5 -8
View File
@@ -5,10 +5,10 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import type { HaECOption } from "../../resources/echarts/echarts";
import { filterXSS } from "../../common/util/xss";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let SunburstChart: typeof import("echarts/lib/chart/sunburst/install");
@@ -50,13 +50,13 @@ export class HaSunburstChart extends LitElement {
return nothing;
}
const options: HaECOption = {
const options = {
tooltip: {
trigger: "item",
formatter: this._renderTooltip,
appendTo: document.body,
},
};
} as ECOption;
return html`<ha-chart-base
.data=${this._createData(this.data)}
@@ -71,10 +71,7 @@ export class HaSunburstChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${data.name}<br />${value}`;
return `${params.marker} ${filterXSS(data.name)}<br>${value}`;
};
private _createData = memoizeOne(
@@ -1,41 +0,0 @@
import { nothing, render } from "lit";
import type { LitTooltipFormatter } from "../../resources/echarts/echarts";
type WrappedTooltipFormatter = (
params: unknown,
ticket?: string
) => HTMLElement | null;
export type { WrappedTooltipFormatter };
const litTooltipFormatterCache = new WeakMap<
LitTooltipFormatter | WrappedTooltipFormatter,
WrappedTooltipFormatter
>();
export const wrapLitTooltipFormatter = (
fn: LitTooltipFormatter | WrappedTooltipFormatter
): WrappedTooltipFormatter => {
const cached = litTooltipFormatterCache.get(fn);
if (cached) return cached;
const container = document.createElement("div");
// display:contents keeps the wrapper layout-invisible so its children act as
// direct children of echarts' tooltip box, matching the prior innerHTML behavior.
container.style.display = "contents";
const wrapped: WrappedTooltipFormatter = (params, ticket) => {
const result = (fn as LitTooltipFormatter)(params, ticket);
// `nothing` and null/undefined must all suppress the tooltip. Returning
// `nothing` to echarts via `render(nothing, container)` leaves a Lit
// comment marker behind so echarts would show an empty box; convert it to
// null instead so `setContent(null)` clears innerHTML and `show()` hides.
if (result === null || result === undefined || result === nothing) {
return null;
}
render(result, container);
return container;
};
litTooltipFormatterCache.set(fn, wrapped);
// Idempotent re-wrap: looking up the wrapped fn returns itself.
litTooltipFormatterCache.set(wrapped, wrapped);
return wrapped;
};
@@ -1,5 +1,5 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
@@ -12,9 +12,8 @@ import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,
@@ -25,6 +24,7 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { filterXSS } from "../../common/util/xss";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
const safeParseFloat = (value) => {
@@ -108,7 +108,7 @@ export class StateHistoryChartLine extends LitElement {
private _datasetToDataIndex: number[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
private _hiddenStats = new Set<string>();
@@ -141,11 +141,12 @@ export class StateHistoryChartLine extends LitElement {
private _renderTooltip = (params: any) => {
const time = params[0].axisValue;
const title = formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
);
const title =
formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
) + "<br>";
const datapoints: Record<string, any>[] = [];
this._chartData.forEach((dataset, index) => {
if (
@@ -176,44 +177,52 @@ export class StateHistoryChartLine extends LitElement {
seriesName: dataset.name,
seriesIndex: index,
value: lastData,
color: dataset.color,
// HTML copied from echarts. May change based on options
marker: `<span style="display:inline-block;margin-right:4px;margin-inline-end:4px;margin-inline-start:initial;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 html`${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]);
const value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
let statSuffix: TemplateResult | typeof nothing = nothing;
if (data.statistics && data.statistics.length > 0) {
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");
// Five non-breaking spaces indent the source label.
statSuffix = html`<br />${"\u00a0".repeat(5)}${source}`;
}
return html`<br /><ha-chart-tooltip-marker
.color=${String(param.color ?? "")}
></ha-chart-tooltip-marker>
${param.seriesName
? html`${param.seriesName}: `
: nothing}${value}${statSuffix}`;
})}`;
return (
title +
datapoints
.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
let value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
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} ${filterXSS(param.seriesName)}: ${value}`;
}
return `${param.marker} ${value}`;
})
.join("<br>")
);
};
private _datasetHidden(ev: CustomEvent) {
@@ -1,10 +1,11 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
ECElementEvent,
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
@@ -14,9 +15,8 @@ import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
@@ -57,7 +57,7 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
@state() private _yWidth = 0;
@@ -69,7 +69,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as HaECSeries}
.data=${this._chartData as ECOption["series"]}
small-controls
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
@@ -132,35 +132,42 @@ export class StateHistoryChartTimeline extends LitElement {
return rect;
};
private _renderTooltip = (params: TooltipPositionCallbackParams) => {
const { value, name, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => {
const { value, name, marker, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`${seriesName
? html`<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: nothing}<ha-chart-tooltip-marker
.color=${String(color ?? "")}
.rtl=${rtl}
></ha-chart-tooltip-marker
>${name}<br />${formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
)}<br />${formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
)}<br />${formattedDuration}`;
};
const markerLocalized = !computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? marker
: `<span style="direction: rtl;display:inline-block;margin-right:4px;margin-inline-end:4px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`;
const lines = [
markerLocalized + name,
formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
),
formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
),
formattedDuration,
].join("<br>");
return [title, lines].join("");
};
public willUpdate(changedProps: PropertyValues) {
if (
+82 -93
View File
@@ -4,7 +4,7 @@ import type {
ZRColor,
} from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } 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";
@@ -34,13 +34,12 @@ import {
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
@@ -127,7 +126,7 @@ export class StatisticsChart extends LitElement {
@state() private _statisticIds: string[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
@state() private _hiddenStats = new Set<string>();
@@ -252,101 +251,91 @@ export class StatisticsChart extends LitElement {
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
const rows: {
time?: string;
color: string;
seriesName?: string;
value: string;
}[] = [];
for (const param of params) {
if (rendered[param.seriesIndex]) continue;
rendered[param.seriesIndex] = true;
return params
.map((param, index: number) => {
if (rendered[param.seriesIndex]) return "";
rendered[param.seriesIndex] = true;
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
startTime = new Date(param.value[0]);
}
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
}
} else {
startTime = new Date(param.value[0]);
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = `${formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
)} <br>`;
}
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(endTime, this.hass.locale, this.hass.config)}`
: "");
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "");
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = formatDateTimeWithSeconds(
new Date(param.value[0]),
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(
rawValue,
this.hass.locale,
this.hass.config
);
}
options
)}${unit}`;
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(rawValue, this.hass.locale, options)}${unit}`;
rows.push({
time: rows.length === 0 ? rawTime : undefined,
color: String(param.color ?? ""),
seriesName: param.seriesName,
value,
});
}
if (rows.length === 0) return nothing;
return html`${rows.map(
(row, i) =>
html`${row.time
? html`${row.time}<br />`
: nothing}<ha-chart-tooltip-marker
.color=${row.color}
></ha-chart-tooltip-marker>
${row.seriesName}:
${row.value}${i < rows.length - 1 ? html`<br />` : nothing}`
)}`;
const time = index === 0 ? rawTime : "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
.filter(Boolean)
.join("<br>");
};
private _createOptions() {
-1
View File
@@ -20,7 +20,6 @@ export class HaCheckListItem extends CheckListItemBase {
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
+14 -7
View File
@@ -1,24 +1,31 @@
import { consume, type ContextType } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { configContext, connectionContext } from "../data/context";
import {
DEFAULT_SERVICE_ICON,
FALLBACK_DOMAIN_ICONS,
serviceIcon,
} from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@customElement("ha-service-icon")
export class HaServiceIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public service?: string;
@property() public icon?: string;
@state()
@consume({ context: connectionContext, subscribe: true })
protected _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: configContext, subscribe: true })
protected _config?: ContextType<typeof configContext>;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -28,13 +35,13 @@ export class HaServiceIcon extends LitElement {
return nothing;
}
if (!this.hass) {
if (!this._connection || !this._config) {
return this._renderFallback();
}
const icon = serviceIcon(
this.hass.connection,
this.hass.config,
this._connection.connection,
this._config?.config,
this.service
).then((icn) => {
if (icn) {
+2 -10
View File
@@ -62,11 +62,7 @@ class HaServicePicker extends LitElement {
index
) => html`
<ha-combo-box-item type="button" .borderTop=${index !== 0}>
<ha-service-icon
slot="start"
.hass=${this.hass}
.service=${item.id}
></ha-service-icon>
<ha-service-icon slot="start" .service=${item.id}></ha-service-icon>
<span slot="headline">${item.primary}</span>
<span slot="supporting-text">${item.secondary}</span>
${item.service_id && this.showServiceId
@@ -112,11 +108,7 @@ class HaServicePicker extends LitElement {
service;
return html`
<ha-service-icon
slot="start"
.hass=${this.hass}
.service=${serviceId}
></ha-service-icon>
<ha-service-icon slot="start" .service=${serviceId}></ha-service-icon>
<span slot="headline">${serviceName}</span>
${this.showServiceId
? html`<span slot="supporting-text" class="code"
+2 -2
View File
@@ -99,8 +99,8 @@ export class HaRadioOption extends Radio {
--ha-radio-option-checked-background-color,
var(--ha-color-fill-primary-normal-resting)
);
color: var(--checked-icon-color);
border-color: var(--checked-icon-color);
color: var(--ha-color-fill-primary-loud-resting);
border-color: var(--ha-color-fill-primary-loud-resting);
}
[part~="label"] {
-1
View File
@@ -451,7 +451,6 @@ export class HatScriptGraph extends LitElement {
${node.action
? html`<ha-service-icon
slot="icon"
.hass=${this.hass}
.service=${node.action}
></ha-service-icon>`
: nothing}
+1
View File
@@ -653,6 +653,7 @@ export interface ActionSidebarConfig extends BaseSidebarConfig {
disable: () => void;
continueOnError: () => void;
duplicate: () => void;
convert: () => void;
cut: () => void;
copy: () => void;
insertAfter: (value: Action | Action[]) => boolean;
+98 -29
View File
@@ -1,6 +1,7 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
@@ -14,7 +15,6 @@ import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
import "../../components/ha-top-app-bar-fixed";
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
@@ -97,36 +97,38 @@ class PanelClimate extends LitElement {
protected render() {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._searchParams.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
<div slot="title">${this.hass.localize("panel.climate")}</div>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
<div class="header ${classMap({ narrow: this.narrow })}">
<div class="toolbar">
${this._searchParams.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing}
</ha-top-app-bar-fixed>
></ha-menu-button>
`}
<div class="main-title">${this.hass.localize("panel.climate")}</div>
</div>
</div>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing}
`;
}
@@ -167,11 +169,78 @@ class PanelClimate extends LitElement {
-webkit-user-select: none;
-moz-user-select: none;
}
.header {
background-color: var(--app-header-background-color);
color: var(--app-header-text-color, white);
position: fixed;
top: 0;
width: calc(
var(--ha-top-app-bar-width, 100%) - var(
--safe-area-inset-right,
0px
)
);
padding-top: var(--safe-area-inset-top);
z-index: 4;
display: flex;
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
backdrop-filter: var(--app-header-backdrop-filter, none);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .header {
width: calc(
var(--ha-top-app-bar-width, 100%) - var(
--safe-area-inset-left,
0px
) - var(--safe-area-inset-right, 0px)
);
padding-left: var(--safe-area-inset-left);
}
:host([scrolled]) .header {
box-shadow: var(
--bar-box-shadow,
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
);
}
.toolbar {
height: var(--header-height);
display: flex;
flex: 1;
align-items: center;
font-size: var(--ha-font-size-xl);
padding: 0px 12px;
font-weight: var(--ha-font-weight-normal);
box-sizing: border-box;
border-bottom: var(--app-header-border-bottom, none);
}
:host([narrow]) .toolbar {
padding: 0 4px;
}
.main-title {
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-normal);
flex-grow: 1;
}
.narrow .main-title {
margin-inline-start: var(--ha-space-2);
}
hui-view-container {
position: relative;
display: flex;
min-height: 100vh;
box-sizing: border-box;
padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
padding-right: var(--safe-area-inset-right);
padding-inline-end: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
}
:host([narrow]) hui-view-container {
padding-left: var(--safe-area-inset-left);
padding-inline-start: var(--safe-area-inset-left);
}
hui-view {
flex: 1 1 100%;
@@ -185,19 +185,19 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
private _renderInfoCard() {
const systemManaged = this._isSystemManaged(this._currentAddon);
return html` <ha-card outlined>
return html`<ha-card outlined>
<div class="card-content">
<div class="addon-header">
${this._currentAddon.logo
? html`
<img
class="logo"
alt=""
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
/>
`
: nothing}
<div class="title">
${this._currentAddon.logo
? html`
<img
class="logo"
alt=""
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
/>
`
: nothing}
${getAppDisplayName(
this._currentAddon.name,
this._currentAddon.stage
@@ -239,7 +239,17 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
? html`<supervisor-apps-state
.state=${this._currentAddon.state}
></supervisor-apps-state>`
: nothing}
: html`
<ha-progress-button
.disabled=${!this._currentAddon.available}
@click=${this._installClicked}
.iconPath=${mdiApplicationImport}
>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.install"
)}
</ha-progress-button>
`}
</div>
<ha-chip-set class="capabilities">
@@ -503,8 +513,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</div>
${(this._currentAddon.update_available && this._updateEntityId) ||
this._computeShowWebUI ||
this._computeShowIngressUI ||
!this._currentAddon.version
this._computeShowIngressUI
? html`
<div class="card-actions">
${this._currentAddon.update_available && this._updateEntityId
@@ -540,19 +549,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</ha-button>
`
: nothing}
${!this._currentAddon.version
? html`
<ha-progress-button
.disabled=${!this._currentAddon.available}
@click=${this._installClicked}
.iconPath=${mdiApplicationImport}
>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.install"
)}
</ha-progress-button>
`
: nothing}
</div>
`
: nothing}
@@ -1501,17 +1497,16 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
}
.addon-header {
display: flex;
padding-inline-start: var(--ha-space-2);
padding-inline-end: initial;
font-size: var(--ha-font-size-2xl);
color: var(--ha-card-header-color, var(--primary-text-color));
align-items: center;
gap: var(--ha-space-2);
flex-wrap: wrap;
margin-bottom: var(--ha-space-4);
}
.addon-header .title {
flex: 1;
margin-inline-end: var(--ha-space-4);
}
.addon-header .title .description {
@@ -1530,15 +1525,17 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--error-color);
margin-bottom: var(--ha-space-4);
}
.description {
margin-bottom: var(--ha-space-4);
}
.description a {
color: var(--primary-color);
}
img.logo {
max-width: 100%;
max-height: 40px;
max-height: 60px;
display: block;
margin-bottom: var(--ha-space-2);
}
ha-assist-chip {
--md-sys-color-primary: var(--text-primary-color);
@@ -1,7 +1,9 @@
import { consume, type ContextType } from "@lit/context";
import { mdiHelpCircle } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-svg-icon";
import { internationalizationContext } from "../../../../data/context";
import type { AddonState } from "../../../../data/hassio/addon";
@@ -14,14 +16,13 @@ class SupervisorAppsState extends LitElement {
private _i18n!: ContextType<typeof internationalizationContext>;
protected render(): TemplateResult {
// Consider "unknown" state as "stopped" for display purposes
// because unknown doesn't add any value to the user
const displayState = this.state === "unknown" ? "stopped" : this.state;
return html`
<div class="dot state-${displayState}"></div>
${this.state === "unknown"
? html`<ha-svg-icon .path=${mdiHelpCircle}></ha-svg-icon>`
: html` <div class="dot state-${this.state}"></div> `}
<span
>${this._i18n.localize(
`ui.panel.config.apps.dashboard.capability.state.${displayState}`
`ui.panel.config.apps.dashboard.capability.state.${this.state}`
)}</span
>
`;
@@ -0,0 +1,196 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeObjectId } from "../../../../common/entity/compute_object_id";
import {
ACTION_BUILDING_BLOCKS,
ACTION_COMBINED_BLOCKS,
} from "../../../../data/action";
import type { Action, ServiceAction } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import { getAutomationActionType } from "./ha-automation-action-row";
type FieldSelector = Record<string, unknown> | undefined;
const getSelectorType = (selector: FieldSelector): string | undefined => {
if (!selector) {
return undefined;
}
const keys = Object.keys(selector);
return keys.length === 1 ? keys[0] : undefined;
};
const getSelectOptionValues = (
selector: FieldSelector
): string[] | undefined => {
const config = selector?.select as
| { options?: readonly (string | { value: string })[] }
| null
| undefined;
if (!config?.options) {
return undefined;
}
return config.options.map((opt) =>
typeof opt === "string" ? opt : opt.value
);
};
/**
* A value is compatible with the new field if either:
* - the new field has no selector (unknown shape accept),
* - the old field had no selector but the new one does (best effort accept),
* - the selector types match. For `select` selectors, additionally require
* that every supplied value is present in the new option list.
*/
const isFieldValueCompatible = (
value: unknown,
oldSelector: FieldSelector,
newSelector: FieldSelector
): boolean => {
const newType = getSelectorType(newSelector);
if (newType === undefined) {
return true;
}
const oldType = getSelectorType(oldSelector);
if (oldType !== undefined && oldType !== newType) {
return false;
}
if (newType === "select") {
const allowed = getSelectOptionValues(newSelector);
if (!allowed) {
return true;
}
const allowedSet = new Set(allowed);
const values = Array.isArray(value) ? value : [value];
return values.every((v) => typeof v === "string" && allowedSet.has(v));
}
return true;
};
const filterTargetEntitiesByDomain = (
target: HassServiceTarget,
domain: string | undefined
): HassServiceTarget => {
if (!domain || target.entity_id === undefined) {
return target;
}
const entityIds = Array.isArray(target.entity_id)
? target.entity_id
: [target.entity_id];
const filtered = entityIds.filter((id) => computeDomain(id) === domain);
const { entity_id: _entityId, ...rest } = target;
if (filtered.length === 0) {
return rest;
}
return { ...rest, entity_id: filtered };
};
export const BASE_ACTION_FIELDS = [
"alias",
"note",
"enabled",
"continue_on_error",
] as const;
const BUILDING_BLOCK_TYPES = new Set<string>([
...ACTION_BUILDING_BLOCKS,
...ACTION_COMBINED_BLOCKS,
]);
export const isBuildingBlockAction = (action: Action): boolean => {
const type = getAutomationActionType(action);
return type !== undefined && BUILDING_BLOCK_TYPES.has(type);
};
/**
* Encode an action as a stable picker key.
* - Service actions `domain.service` (always contains a dot).
* - Everything else the action type identifier (no dot).
*/
export const getActionKey = (action: Action): string | undefined => {
const type = getAutomationActionType(action);
if (type === "service") {
return (action as ServiceAction).action || undefined;
}
return type;
};
const isServiceKey = (key: string) => key.includes(".");
/** Build a fresh action with default content for the given picker key. */
export const buildActionFromKey = (key: string): Action => {
if (isServiceKey(key)) {
return {
action: key,
metadata: {},
} as ServiceAction;
}
const elClass = customElements.get(`ha-automation-action-${key}`) as
| (CustomElementConstructor & { defaultConfig?: Action })
| undefined;
if (elClass?.defaultConfig) {
return { ...elClass.defaultConfig };
}
return { [key]: {} } as Action;
};
const isServiceAction = (action: Action): action is ServiceAction =>
"action" in action && !!(action as ServiceAction).action;
/**
* Merge fields from `oldAction` into `newAction` that are still compatible.
* Behavior: copy base fields if old has them, and for service service copy
* `target` and any `data` keys that the new service still supports.
*/
export const convertAction = (
oldAction: Action,
newAction: Action,
services: HomeAssistant["services"]
): Action => {
const merged: Action = { ...newAction };
for (const field of BASE_ACTION_FIELDS) {
const oldValue = (oldAction as Record<string, unknown>)[field];
if (oldValue !== undefined) {
(merged as Record<string, unknown>)[field] = oldValue;
}
}
if (isServiceAction(oldAction) && isServiceAction(merged)) {
if (oldAction.target) {
const newDomain = merged.action
? computeDomain(merged.action)
: undefined;
merged.target = filterTargetEntitiesByDomain(oldAction.target, newDomain);
}
if (oldAction.data && merged.action && oldAction.action) {
const newDomain = computeDomain(merged.action);
const newService = computeObjectId(merged.action);
const newFields = services[newDomain]?.[newService]?.fields;
const oldDomain = computeDomain(oldAction.action);
const oldService = computeObjectId(oldAction.action);
const oldFields = services[oldDomain]?.[oldService]?.fields;
if (newFields) {
const carried: Record<string, unknown> = {};
for (const [key, value] of Object.entries(oldAction.data)) {
const newField = newFields[key];
if (
newField &&
isFieldValueCompatible(
value,
oldFields?.[key]?.selector,
newField.selector
)
) {
carried[key] = value;
}
}
if (Object.keys(carried).length > 0) {
merged.data = carried;
}
}
}
}
return merged;
};
@@ -0,0 +1,400 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiArrowRight, mdiRoomService } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-generic-picker";
import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-box";
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
import "../../../../components/ha-service-icon";
import "../../../../components/ha-svg-icon";
import "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import {
ACTION_BUILDING_BLOCKS,
ACTION_COMBINED_BLOCKS,
ACTION_ICONS,
YAML_ONLY_ACTION_TYPES,
} from "../../../../data/action";
import {
internationalizationContext,
servicesContext,
} from "../../../../data/context";
import { domainToName } from "../../../../data/integration";
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
import type { HomeAssistant } from "../../../../types";
import {
buildActionFromKey,
convertAction,
getActionKey,
} from "./convert-action";
import type { ConvertActionDialogParams } from "./show-dialog-convert-action";
interface ConvertItem extends PickerComboBoxItem {
isService: boolean;
}
const EXCLUDED_TYPES = new Set<string>([
...ACTION_BUILDING_BLOCKS,
...ACTION_COMBINED_BLOCKS,
...YAML_ONLY_ACTION_TYPES,
// Generic catch-all action types covered by the services list below
"service",
// Umbrella repeat — not a leaf type
"repeat",
]);
@customElement("dialog-convert-action")
class DialogConvertAction extends DialogMixin<ConvertActionDialogParams>(
LitElement
) {
@state() private _pickedKey?: string;
@state() private _mode: "current" | "new" = "current";
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: servicesContext, subscribe: true })
private _services!: ContextType<typeof servicesContext>;
public connectedCallback(): void {
super.connectedCallback();
if (this.params) {
this._pickedKey = getActionKey(this.params.currentAction);
this._mode = "current";
}
}
protected render() {
if (!this.params) {
return nothing;
}
const currentKey = getActionKey(this.params.currentAction);
const canCommit =
this._pickedKey !== undefined && this._pickedKey !== currentKey;
return html`
<ha-dialog
open
header-title=${this._i18n.localize(
"ui.panel.config.automation.editor.actions.convert_dialog.title"
)}
>
<div class="content">
${currentKey
? html`
<div class="preview">
${this._renderActionChip(currentKey)}
<ha-svg-icon
class="arrow"
.path=${mdiArrowRight}
></ha-svg-icon>
${this._pickedKey && this._pickedKey !== currentKey
? this._renderActionChip(this._pickedKey)
: "?"}
</div>
`
: nothing}
<ha-generic-picker
required
.value=${this._pickedKey}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer(
this._i18n.localize,
this._services
)}
.notFoundLabel=${this._i18n.localize(
"ui.panel.config.automation.editor.actions.convert_dialog.no_matches"
)}
@value-changed=${this._pickedKeyChanged}
></ha-generic-picker>
<ha-alert alert-type="warning">
${this._i18n.localize(
"ui.panel.config.automation.editor.actions.convert_dialog.warning"
)}
</ha-alert>
<ha-radio-group
name="mode"
.value=${this._mode}
@change=${this._modeChanged}
>
<ha-radio-option value="current">
${this._i18n.localize(
"ui.panel.config.automation.editor.actions.convert_dialog.mode_current"
)}
</ha-radio-option>
<ha-radio-option value="new">
${this._i18n.localize(
"ui.panel.config.automation.editor.actions.convert_dialog.mode_new"
)}
</ha-radio-option>
</ha-radio-group>
</div>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this._i18n.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!canCommit}
@click=${this._convert}
>
${this._i18n.localize(
"ui.panel.config.automation.editor.actions.convert_dialog.convert"
)}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _items = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"]
): ConvertItem[] => {
const items: ConvertItem[] = [];
for (const type of Object.keys(ACTION_ICONS)) {
if (EXCLUDED_TYPES.has(type)) {
continue;
}
const label =
localize(
`ui.panel.config.automation.editor.actions.type.${type}.label` as any
) || type;
items.push({
id: type,
primary: label,
icon_path: ACTION_ICONS[type as keyof typeof ACTION_ICONS],
isService: false,
sorting_label: `0_${label}`,
});
}
if (services) {
for (const domain of Object.keys(services)) {
const domainName = domainToName(localize, domain);
for (const service of Object.keys(services[domain])) {
const serviceId = `${domain}.${service}`;
const def = services[domain][service];
const serviceName =
localize(
`component.${domain}.services.${service}.name` as any,
def.description_placeholders
) ||
def.name ||
service;
const description =
localize(
`component.${domain}.services.${service}.description` as any,
def.description_placeholders
) ||
def.description ||
"";
items.push({
id: serviceId,
primary: `${domainName}: ${serviceName}`,
secondary: description,
isService: true,
search_labels: {
serviceId,
domainName,
serviceName,
description,
},
sorting_label: `1_${domainName}_${serviceName}`,
});
}
}
}
return items;
}
);
private _getItems = () => this._items(this._i18n.localize, this._services);
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (
item,
index
) => {
const convertItem = item as ConvertItem;
return html`
<ha-combo-box-item type="button" .borderTop=${index !== 0}>
${convertItem.isService
? html`<ha-service-icon
slot="start"
.service=${item.id}
></ha-service-icon>`
: html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
};
private _valueRenderer = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"]
): PickerValueRenderer =>
(value) => {
if (value.includes(".")) {
const [domain, service] = value.split(".");
const def = services?.[domain]?.[service];
const domainName = domainToName(localize, domain);
const serviceName =
localize(
`component.${domain}.services.${service}.name` as any,
def?.description_placeholders
) ||
def?.name ||
service;
return html`
<ha-service-icon slot="start" .service=${value}></ha-service-icon>
<span slot="headline">${domainName}: ${serviceName}</span>
`;
}
const iconPath = ACTION_ICONS[value as keyof typeof ACTION_ICONS];
const label =
localize(
`ui.panel.config.automation.editor.actions.type.${value}.label` as any
) || value;
return html`
${iconPath
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiRoomService}
></ha-svg-icon>`}
<span slot="headline">${label}</span>
`;
}
);
private _renderActionChip(key: string) {
const localize = this._i18n.localize;
if (key.includes(".")) {
const [domain, service] = key.split(".");
const def = this._services?.[domain]?.[service];
const domainName = domainToName(localize, domain);
const serviceName =
localize(
`component.${domain}.services.${service}.name` as any,
def?.description_placeholders
) ||
def?.name ||
service;
return html`
<div class="chip">
<ha-service-icon .service=${key}></ha-service-icon>
<span>${domainName}: ${serviceName}</span>
</div>
`;
}
const iconPath = ACTION_ICONS[key as keyof typeof ACTION_ICONS];
const label =
localize(
`ui.panel.config.automation.editor.actions.type.${key}.label` as any
) || key;
return html`
<div class="chip">
<ha-svg-icon .path=${iconPath || mdiRoomService}></ha-svg-icon>
<span>${label}</span>
</div>
`;
}
private _pickedKeyChanged = (ev: CustomEvent) => {
ev.stopPropagation();
this._pickedKey = ev.detail?.value || undefined;
};
private _modeChanged = (ev: Event) => {
const value = (ev.target as HTMLInputElement).value;
if (value === "current" || value === "new") {
this._mode = value;
}
};
private _convert = () => {
if (!this.params || !this._pickedKey) {
return;
}
const newAction = buildActionFromKey(this._pickedKey);
const merged = convertAction(
this.params.currentAction,
newAction,
this._services
);
if (this._mode === "new") {
this.params.duplicateConvert(merged);
} else {
this.params.convert(merged);
}
this.closeDialog();
};
static styles = css`
ha-dialog {
--mdc-dialog-min-width: min(560px, 100vw);
}
.content {
display: flex;
flex-direction: column;
gap: var(--ha-space-3);
}
.preview {
display: flex;
align-items: center;
gap: var(--ha-space-2);
flex-wrap: wrap;
}
.preview .chip {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
padding: var(--ha-space-1) var(--ha-space-3);
border-radius: 999px;
background-color: var(--secondary-background-color);
min-width: 0;
}
.preview .chip span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview .arrow {
color: var(--secondary-text-color);
flex-shrink: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-convert-action": DialogConvertAction;
}
}
@@ -20,6 +20,7 @@ import {
mdiPlusCircleMultipleOutline,
mdiRenameBox,
mdiStopCircleOutline,
mdiSwapHorizontal,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassServiceTarget } from "home-assistant-js-websocket";
@@ -90,8 +91,10 @@ import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import "../target/ha-automation-row-targets";
import { isBuildingBlockAction } from "./convert-action";
import "./ha-automation-action-editor";
import type HaAutomationActionEditor from "./ha-automation-action-editor";
import { showConvertActionDialog } from "./show-dialog-convert-action";
import "./types/ha-automation-action-choose";
import "./types/ha-automation-action-condition";
import "./types/ha-automation-action-delay";
@@ -308,7 +311,6 @@ export default class HaAutomationActionRow extends LitElement {
<ha-service-icon
slot="leading-icon"
class="action-icon"
.hass=${this.hass}
.service=${this.action.action}
></ha-service-icon>
`
@@ -334,15 +336,13 @@ export default class HaAutomationActionRow extends LitElement {
? this._renderTargets(
target,
actionHasTarget && !this._isNew,
serviceTargetSpec,
type !== "device_id"
serviceTargetSpec
)
: nothing}
${noteTooltipText
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -522,6 +522,16 @@ export default class HaAutomationActionRow extends LitElement {
></ha-dropdown-item>
`
: nothing}
${!isBuildingBlockAction(this.action)
? html`<ha-dropdown-item value="convert" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiSwapHorizontal}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.actions.convert"
)
)}
</ha-dropdown-item>`
: nothing}
<ha-dropdown-item
value="toggle_yaml_mode"
@@ -723,14 +733,13 @@ export default class HaAutomationActionRow extends LitElement {
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"],
interactive = false
targetSpec?: TargetSelector["target"]
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
@@ -980,6 +989,24 @@ export default class HaAutomationActionRow extends LitElement {
fireEvent(this, "duplicate");
};
private _convertAction = () => {
showConvertActionDialog(this, {
currentAction: this.action,
convert: (newAction) => {
fireEvent(this, "value-changed", { value: newAction });
if (this._selected && this.optionsInSidebar) {
this.openSidebar(newAction);
} else if (this._yamlMode) {
this._actionEditor?.yamlEditor?.setValue(newAction);
}
},
duplicateConvert: (newAction) => {
this._insertAfter(newAction);
},
});
};
private _insertAfter = (value: Action | Action[]) => {
if (ensureArray(value).some((val) => !isAction(val))) {
return false;
@@ -1105,6 +1132,7 @@ export default class HaAutomationActionRow extends LitElement {
paste: this._pasteAction,
pasteAvailable: this._pasteAvailable,
duplicate: this._duplicateAction,
convert: this._convertAction,
insertAfter: this._insertAfter,
run: this._runAction,
config: {
@@ -1209,6 +1237,9 @@ export default class HaAutomationActionRow extends LitElement {
case "move_down":
this._moveDown();
break;
case "convert":
this._convertAction();
break;
case "toggle_yaml_mode":
this._toggleYamlMode(ev.target as HTMLElement);
break;
@@ -0,0 +1,21 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { Action } from "../../../../data/script";
export interface ConvertActionDialogParams {
currentAction: Action;
convert: (newAction: Action) => void;
duplicateConvert: (newAction: Action) => void;
}
const loadDialog = () => import("./dialog-convert-action");
export const showConvertActionDialog = (
element: HTMLElement,
dialogParams: ConvertActionDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-convert-action",
dialogImport: loadDialog,
dialogParams,
});
};
@@ -30,10 +30,10 @@ import type {
LocalizeFunc,
LocalizeKeys,
} from "../../../common/translations/localize";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import { computeRTL } from "../../../common/util/compute_rtl";
import { debounce } from "../../../common/util/debounce";
import { deepEqual } from "../../../common/util/deep-equal";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import "../../../components/entity/state-badge";
import "../../../components/ha-bottom-sheet";
import "../../../components/ha-button";
@@ -134,8 +134,8 @@ import {
ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
PASTE_VALUE,
getAddAutomationElementTargetFromQuery,
PASTE_VALUE,
} from "./show-add-automation-element-dialog";
import { getTargetText } from "./target/get_target_text";
@@ -795,33 +795,37 @@ class DialogAddAutomationElement
class="paste"
@click=${this._paste}
>
<div slot="headline" class="label">
${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.paste`
)}
<div class="shortcut-label">
<div class="label">
<div>
${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.paste`
)}
</div>
<div class="supporting-text">
${this.hass.localize(
// @ts-ignore
`ui.panel.config.automation.editor.${automationElementType}s.type.${this._params.clipboardItem}.label`
)}
</div>
</div>
${!this._narrow
? html`<span class="shortcut">
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
"ui.panel.config.automation.editor.ctrl"
)}</span
>
<span>+</span>
<span>V</span>
</span>`
: nothing}
</div>
<div slot="supporting-text">
${this.hass.localize(
// @ts-ignore
`ui.panel.config.automation.editor.${automationElementType}s.type.${this._params.clipboardItem}.label`
)}
</div>
${!this._narrow
? html`<span slot="end" class="shortcut">
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
"ui.panel.config.automation.editor.ctrl"
)}</span
>
<span>+</span>
<span>V</span>
</span>`
: nothing}
<ha-svg-icon
slot="start"
.path=${mdiContentPaste}
@@ -1634,7 +1638,6 @@ class DialogAddAutomationElement
result.push({
icon: html`
<ha-service-icon
.hass=${this.hass}
.service=${`${dmn}.${service}`}
></ha-service-icon>
`,
@@ -1953,7 +1956,6 @@ class DialogAddAutomationElement
items[domain].items.push({
icon: html`
<ha-service-icon
.hass=${this.hass}
.service=${`${domain}.${serviceName}`}
></ha-service-icon>
`,
@@ -2542,16 +2544,23 @@ class DialogAddAutomationElement
ha-svg-icon.plus {
color: var(--primary-color);
}
.shortcut {
.shortcut-label {
display: flex;
gap: var(--ha-space-3);
justify-content: space-between;
}
.shortcut-label .supporting-text {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.shortcut-label .shortcut {
--mdc-icon-size: var(--ha-space-3);
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 2px;
margin-right: var(--ha-space-4);
}
.shortcut span {
.shortcut-label .shortcut span {
font-size: var(--ha-font-size-s);
font-family: var(--ha-font-family-code);
color: var(--ha-color-text-secondary);
@@ -224,15 +224,13 @@ export default class HaAutomationConditionRow extends LitElement {
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
conditionTargetSpec,
this.condition.condition !== "device"
conditionTargetSpec
)
: nothing}
${this.condition.note?.trim()
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -575,14 +573,13 @@ export default class HaAutomationConditionRow extends LitElement {
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"],
interactive = false
targetSpec?: TargetSelector["target"]
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
@@ -161,7 +161,6 @@ export default class HaAutomationOptionRow extends LitElement {
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -14,6 +14,7 @@ import {
mdiPlusCircleMultipleOutline,
mdiRenameBox,
mdiStopCircleOutline,
mdiSwapHorizontal,
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
@@ -27,6 +28,7 @@ import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import { ACTION_BUILDING_BLOCKS } from "../../../../data/action";
import type { ActionSidebarConfig } from "../../../../data/automation";
import { isBuildingBlockAction } from "../action/convert-action";
import type { DomainManifestLookup } from "../../../../data/integration";
import { domainToName } from "../../../../data/integration";
import type {
@@ -285,6 +287,22 @@ export default class HaAutomationSidebarAction extends LitElement {
</ha-dropdown-item>
`
: nothing}
${!isBuildingBlockAction(this.config.config.action)
? html`<ha-dropdown-item
slot="menu-items"
value="convert"
.disabled=${this.disabled}
>
<ha-svg-icon slot="icon" .path=${mdiSwapHorizontal}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.convert"
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-dropdown-item>`
: nothing}
<ha-dropdown-item
slot="menu-items"
value="toggle_yaml_mode"
@@ -451,6 +469,9 @@ export default class HaAutomationSidebarAction extends LitElement {
case "duplicate":
this.config.duplicate();
break;
case "convert":
this.config.convert();
break;
case "copy":
this.config.copy();
break;
@@ -60,9 +60,6 @@ export class HaAutomationRowTargets extends LitElement {
@property({ attribute: false })
public selector?: TargetSelector;
@property({ type: Boolean })
public interactive = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -92,12 +89,7 @@ export class HaAutomationRowTargets extends LitElement {
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
private _countCache = new Map<
string,
Promise<number | undefined> | number | undefined
>();
private _rerenderCount = true;
private _countCache = new Map<string, Promise<number | undefined>>();
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
@@ -106,15 +98,10 @@ export class HaAutomationRowTargets extends LitElement {
changedProps.has("selector") ||
changedProps.has("_registries")
) {
this._rerenderCount = true;
this._countCache.clear();
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
this._rerenderCount = false;
}
private _countMatchingEntities(referencedEntities: string[]): number {
const targetSelector = this.selector;
const hasEntityFilter = !!targetSelector?.target?.entity;
@@ -161,11 +148,7 @@ export class HaAutomationRowTargets extends LitElement {
targetId: string
) {
const key = `${targetType}:${targetId}`;
let fallback = " (-)";
if (!this._countCache.has(key) || this._rerenderCount) {
if (typeof this._countCache.get(key) === "number") {
fallback = ` (${this._countCache.get(key)})`;
}
if (!this._countCache.has(key)) {
this._countCache.set(
key,
extractFromTarget(
@@ -179,30 +162,15 @@ export class HaAutomationRowTargets extends LitElement {
.then((result) =>
this._countMatchingEntities(result.referenced_entities)
)
.catch((err) => {
// eslint-disable-next-line no-console
console.error("Error counting target entities", err);
return undefined;
})
.catch(() => undefined)
);
}
if (this._countCache.get(key) instanceof Promise) {
return until(
(this._countCache.get(key) as Promise<number | undefined>)!.then(
(count) => {
this._countCache.set(key, count);
return count === undefined ? nothing : html` (${count})`;
}
),
fallback
);
}
if (typeof this._countCache.get(key) === "number") {
return ` (${this._countCache.get(key)})`;
}
return nothing;
return until(
this._countCache
.get(key)!
.then((count) => (count === undefined ? nothing : html` (${count})`)),
"(-)"
);
}
protected render() {
@@ -281,9 +249,8 @@ export class HaAutomationRowTargets extends LitElement {
<ha-dropdown
@wa-select=${this._handleTargetSelect}
@click=${stopPropagation}
@keydown=${stopPropagation}
>
<button slot="trigger" class="target">
<span slot="trigger" class="target interactive">
<ha-svg-icon .path=${mdiFormatListBulleted}></ha-svg-icon>
<div class="label">
${this._i18n.localize(
@@ -294,7 +261,7 @@ export class HaAutomationRowTargets extends LitElement {
)}
</div>
<ha-svg-icon .path=${mdiMenuDown}></ha-svg-icon>
</button>
</span>
${rows.map(([targetType, targetId]) => {
const content = html`${lastTargetType !== null &&
lastTargetType !== targetType
@@ -349,37 +316,21 @@ export class HaAutomationRowTargets extends LitElement {
targetType?: string,
countTemplate: unknown = nothing
) {
if (!this.interactive || !targetId || !targetType) {
return html`<div
class=${classMap({
target: true,
warning,
error,
})}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
>
${icon}
<div class="label">${label}${countTemplate}</div>
</div>`;
}
return html`<button
return html`<div
class=${classMap({
target: true,
warning,
error,
interactive: targetId && targetType,
})}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@click=${this._handleTargetClick}
@keydown=${this._handleTargetKeydown}
>
${icon}
<div class="label">${label}${countTemplate}</div>
</button>`;
</div>`;
}
private _renderTarget(
@@ -433,7 +384,7 @@ export class HaAutomationRowTargets extends LitElement {
targetId,
this._getLabel
);
if (targetType !== "entity" && this.interactive) {
if (targetType !== "entity") {
countTemplate = this._renderCount(targetType, targetId);
}
}
@@ -493,13 +444,6 @@ export class HaAutomationRowTargets extends LitElement {
this._showTargetInfo(target.targetId, target.targetType, target.label, ev);
}
private _handleTargetKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleTargetClick(ev);
}
}
private _handleTargetSelect(
ev: HaDropdownSelectEvent<{
targetId?: string;
@@ -589,10 +533,10 @@ export class HaAutomationRowTargets extends LitElement {
align-items: center;
}
button.target {
.target.interactive {
cursor: pointer;
}
button.target:hover {
.target.interactive:hover {
background: var(--ha-color-fill-neutral-normal-hover);
}
@@ -249,15 +249,13 @@ export default class HaAutomationTriggerRow extends LitElement {
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
triggerTargetSpec,
type !== "device"
triggerTargetSpec
)
: nothing}
${type !== "list" &&
(this.trigger as Exclude<Trigger, TriggerList>).note?.trim()
? html`
<ha-svg-icon
tabindex="0"
id="note-icon"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
@@ -559,14 +557,13 @@ export default class HaAutomationTriggerRow extends LitElement {
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"],
interactive = false
targetSpec?: TargetSelector["target"]
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
@@ -217,7 +217,7 @@ export class CloudRegister extends LitElement {
try {
await cloudRegister(this.hass, email, password);
this._verificationEmailSent(email, "account_created");
this._verificationEmailSent(email);
} catch (err: any) {
this._password = "";
this._requestInProgress = false;
@@ -238,18 +238,15 @@ export class CloudRegister extends LitElement {
const email = emailField.value || "";
this._requestInProgress = true;
const doResend = async (username: string) => {
try {
await cloudResendVerification(this.hass, username);
this._verificationEmailSent(username, "verification_email_sent");
this._verificationEmailSent(username);
} catch (err: any) {
const errCode = err && err.body && err.body.code;
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
await doResend(username.toLowerCase());
} else {
this._requestInProgress = false;
this._error =
err && err.body && err.body.message
? err.body.message
@@ -261,16 +258,13 @@ export class CloudRegister extends LitElement {
await doResend(email);
}
private _verificationEmailSent(
email: string,
messageKey: "account_created" | "verification_email_sent"
) {
private _verificationEmailSent(email: string) {
this._requestInProgress = false;
this._password = "";
fireEvent(this, "cloud-email-changed", { value: email });
fireEvent(this, "cloud-done", {
flashMessage: this.hass.localize(
`ui.panel.config.cloud.register.${messageKey}`
"ui.panel.config.cloud.register.account_created"
),
});
}
@@ -330,48 +330,42 @@ export class BluetoothNetworkVisualization extends LitElement {
return rssi > -33 ? 3 : rssi > -66 ? 2 : 1;
}
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
const { dataType, data } = params as CallbackDataParams;
let tooltipText = "";
if (dataType === "edge") {
const { source, target, value } = data as any;
const sourceName = this._getBluetoothDeviceName(source);
const targetName = this._getBluetoothDeviceName(target);
return html`${sourceName}
${targetName}${source !== CORE_SOURCE_ID
? html` <b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b>
${value}`
: nothing}`;
tooltipText = `${sourceName} ${targetName}`;
if (source !== CORE_SOURCE_ID) {
tooltipText += ` <b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${value}`;
}
} else {
const { id: address } = data as any;
const name = this._getBluetoothDeviceName(address);
const btDevice = this._data.find((d) => d.address === address);
if (btDevice) {
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}<br><b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${btDevice.rssi}<br><b>${this.hass.localize("ui.panel.config.bluetooth.source")}:</b> ${btDevice.source}<br><b>${this.hass.localize("ui.panel.config.bluetooth.updated")}:</b> ${relativeTime(new Date(btDevice.time * 1000), this.hass.locale)}`;
const device = this._sourceDevices[address];
if (device) {
const area = getDeviceArea(device, this.hass.areas);
if (area) {
tooltipText += `<br><b>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b>${area.name}`;
}
}
} else {
const device = this._sourceDevices[address];
if (device) {
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}`;
const area = getDeviceArea(device, this.hass.areas);
if (area) {
tooltipText += `<br><b>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b>${area.name}`;
}
}
}
}
const { id: address } = data as any;
const name = this._getBluetoothDeviceName(address);
const btDevice = this._data.find((d) => d.address === address);
const device = this._sourceDevices[address];
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
const areaLine = area
? html`<br /><b
>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b
>${area.name}`
: nothing;
if (btDevice) {
return html`<b>${name}</b><br />
<b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b>
${address}<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b>
${btDevice.rssi}<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.source")}:</b>
${btDevice.source}<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.updated")}:</b>
${relativeTime(
new Date(btDevice.time * 1000),
this.hass.locale
)}${areaLine}`;
}
if (device) {
return html`<b>${name}</b><br />
<b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b>
${address}${areaLine}`;
}
return nothing;
return tooltipText;
};
private _handleChartClick(e: CustomEvent): void {
@@ -131,7 +131,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
}
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
const { dataType, data, name } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target, value } = data as any;
@@ -141,45 +141,40 @@ export class ZHANetworkVisualizationPage extends LitElement {
const sourceName = this._networkData.nodes.find(
(node) => node.id === source
)!.name;
const tooltipText = `${sourceName}${targetName}${value ? ` <b>LQI:</b> ${value}` : ""}`;
const reverseValue = this._networkData.links.find(
(link) => link.source === source && link.target === target
)?.reverseValue;
return html`${sourceName}
${targetName}${value
? html` <b>LQI:</b> ${value}`
: nothing}${reverseValue
? html`<br />${targetName}${sourceName} <b>LQI:</b> ${reverseValue}`
: nothing}`;
if (reverseValue) {
return `${tooltipText}<br>${targetName}${sourceName} <b>LQI:</b> ${reverseValue}`;
}
return tooltipText;
}
const device = this._devices.find((d) => d.ieee === (data as any).id);
if (!device) {
return html`${name}`;
return name;
}
let label = `<b>IEEE: </b>${device.ieee}`;
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device_type")}: </b>${device.device_type.replace("_", " ")}`;
if (device.nwk != null) {
label += `<br><b>NWK: </b>${formatAsPaddedHex(device.nwk)}`;
}
if (device.manufacturer != null && device.model != null) {
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device")}: </b>${device.manufacturer} ${device.model}`;
} else {
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device_not_in_db")}</b>`;
}
const haDevice = this.hass.devices[device.device_reg_id] as
| DeviceRegistryEntry
| undefined;
const area = haDevice
? getDeviceArea(haDevice, this.hass.areas)
: undefined;
return html`<b>IEEE: </b>${device.ieee}<br /><b
>${this.hass.localize("ui.panel.config.zha.visualization.device_type")}: </b
>${device.device_type.replace("_", " ")}${device.nwk != null
? html`<br /><b>NWK: </b>${formatAsPaddedHex(device.nwk)}`
: nothing}${device.manufacturer != null && device.model != null
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zha.visualization.device"
)}: </b
>${device.manufacturer} ${device.model}`
: html`<br /><b
>${this.hass.localize(
"ui.panel.config.zha.visualization.device_not_in_db"
)}</b
>`}${area
? html`<br /><b
>${this.hass.localize("ui.panel.config.zha.visualization.area")}: </b
>${area.name}`
: nothing}`;
if (haDevice) {
const area = getDeviceArea(haDevice, this.hass.areas);
if (area) {
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.area")}: </b>${area.name}`;
}
}
return label;
};
private async _refreshTopology(): Promise<void> {
@@ -9,7 +9,6 @@ import { getDeviceArea } from "../../../../../common/entity/context/get_device_c
import { navigate } from "../../../../../common/navigate";
import { debounce } from "../../../../../common/util/debounce";
import "../../../../../components/chart/ha-network-graph";
import "../../../../../components/chart/ha-chart-tooltip-marker";
import type {
NetworkData,
NetworkLink,
@@ -151,7 +150,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
}
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
const { dataType, data } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target, value } = data as any;
@@ -161,66 +160,39 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
const targetName =
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
let tip = `${sourceName}${targetName}`;
const route =
this._nodeStatistics[source]?.lwr || this._nodeStatistics[source]?.nlwr;
return html`${sourceName}
${targetName}${route?.protocol_data_rate
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.data_rate"
)}:</b
>
${this.hass.localize(
`ui.panel.config.zwave_js.protocol_data_rate.${route.protocol_data_rate}` as any
)}`
: nothing}${value ? html`<br /><b>RSSI:</b> ${value}` : nothing}`;
if (route?.protocol_data_rate) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.data_rate")}:</b> ${this.hass.localize(`ui.panel.config.zwave_js.protocol_data_rate.${route.protocol_data_rate}`)}`;
}
if (value) {
tip += `<br><b>RSSI:</b> ${value}`;
}
return tip;
}
const { id, name } = data as any;
const device = this._devices[id] as DeviceRegistryEntry | undefined;
const nodeStatus = this._nodeStatuses[id];
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
return html`<ha-chart-tooltip-marker
.color=${String((params as CallbackDataParams).color ?? "")}
></ha-chart-tooltip-marker>
${name}<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.node_id"
)}:</b
>
${id}${device
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.manufacturer"
)}:</b
>
${device.manufacturer || "-"}<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.model"
)}:</b
>
${device.model || "-"}`
: nothing}${nodeStatus
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.status"
)}:</b
>
${this.hass.localize(
`ui.panel.config.zwave_js.node_status.${nodeStatus.status}` as any
)}${nodeStatus.zwave_plus_version
? html`<br /><b>Z-Wave Plus:</b> ${this.hass.localize(
"ui.panel.config.zwave_js.visualization.version"
)}
${nodeStatus.zwave_plus_version}`
: nothing}`
: nothing}${area
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.area"
)}:</b
>
${area.name}`
: nothing}`;
let tip = `${(params as any).marker} ${name}`;
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.node_id")}:</b> ${id}`;
if (device) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.manufacturer")}:</b> ${device.manufacturer || "-"}`;
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.model")}:</b> ${device.model || "-"}`;
}
if (nodeStatus) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.status")}:</b> ${this.hass.localize(`ui.panel.config.zwave_js.node_status.${nodeStatus.status}`)}`;
if (nodeStatus.zwave_plus_version) {
tip += `<br><b>Z-Wave Plus:</b> ${this.hass.localize("ui.panel.config.zwave_js.visualization.version")} ${nodeStatus.zwave_plus_version}`;
}
}
if (device) {
const area = getDeviceArea(device, this.hass.areas);
if (area) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.area")}:</b> ${area.name}`;
}
}
return tip;
};
private _getNetworkData = memoizeOne(
@@ -158,7 +158,6 @@ export default class HaScriptFieldRow extends LitElement {
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
+98 -29
View File
@@ -1,6 +1,7 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
@@ -14,7 +15,6 @@ import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
import "../../components/ha-top-app-bar-fixed";
const LIGHT_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
@@ -97,36 +97,38 @@ class PanelLight extends LitElement {
protected render() {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
<div slot="title">${this.hass.localize("panel.light")}</div>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
<div class="header ${classMap({ narrow: this.narrow })}">
<div class="toolbar">
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing}
</ha-top-app-bar-fixed>
></ha-menu-button>
`}
<div class="main-title">${this.hass.localize("panel.light")}</div>
</div>
</div>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing}
`;
}
@@ -167,11 +169,78 @@ class PanelLight extends LitElement {
-webkit-user-select: none;
-moz-user-select: none;
}
.header {
background-color: var(--app-header-background-color);
color: var(--app-header-text-color, white);
position: fixed;
top: 0;
width: calc(
var(--ha-top-app-bar-width, 100%) - var(
--safe-area-inset-right,
0px
)
);
padding-top: var(--safe-area-inset-top);
z-index: 4;
display: flex;
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
backdrop-filter: var(--app-header-backdrop-filter, none);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .header {
width: calc(
var(--ha-top-app-bar-width, 100%) - var(
--safe-area-inset-left,
0px
) - var(--safe-area-inset-right, 0px)
);
padding-left: var(--safe-area-inset-left);
}
:host([scrolled]) .header {
box-shadow: var(
--bar-box-shadow,
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
);
}
.toolbar {
height: var(--header-height);
display: flex;
flex: 1;
align-items: center;
font-size: var(--ha-font-size-xl);
padding: 0px 12px;
font-weight: var(--ha-font-weight-normal);
box-sizing: border-box;
border-bottom: var(--app-header-border-bottom, none);
}
:host([narrow]) .toolbar {
padding: 0 4px;
}
.main-title {
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-normal);
flex-grow: 1;
}
.narrow .main-title {
margin-inline-start: var(--ha-space-2);
}
hui-view-container {
position: relative;
display: flex;
min-height: 100vh;
box-sizing: border-box;
padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
padding-right: var(--safe-area-inset-right);
padding-inline-end: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
}
:host([narrow]) hui-view-container {
padding-left: var(--safe-area-inset-left);
padding-inline-start: var(--safe-area-inset-left);
}
hui-view {
flex: 1 1 100%;
@@ -1,6 +1,4 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { html, nothing } from "lit";
import {
subHours,
differenceInDays,
@@ -33,10 +31,10 @@ import {
formatDateWeekdayVeryShortDate,
} from "../../../../../common/datetime/format_date";
import { formatTime } from "../../../../../common/datetime/format_time";
import type { HaECOption } from "../../../../../resources/echarts/echarts";
import type { ECOption } from "../../../../../resources/echarts/echarts";
import { filterXSS } from "../../../../../common/util/xss";
import type { StatisticPeriod } from "../../../../../data/recorder";
import { getPeriodicAxisLabelConfig } from "../../../../../components/chart/axis-label";
import "../../../../../components/chart/ha-chart-tooltip-marker";
import { getSuggestedPeriod } from "../../../../../data/energy";
export { fillDataGapsAndRoundCaps } from "../../../../../components/chart/round-caps";
@@ -112,7 +110,7 @@ export function getCommonOptions(
formatTotal?: (total: number) => string,
detailedDailyData = false,
yAxisFractionDigits = 1
): HaECOption {
): ECOption {
const suggestedPeriod = getSuggestedPeriod(start, end, detailedDailyData);
let suggestedMax = getSuggestedMax(suggestedPeriod, end, detailedDailyData);
@@ -136,7 +134,7 @@ export function getCommonOptions(
}
}
const monthTimeAxis: HaECOption = {
const monthTimeAxis: ECOption = {
xAxis: {
type: "time",
min: subDays(start, MONTH_TIME_AXIS_PADDING),
@@ -148,7 +146,7 @@ export function getCommonOptions(
splitNumber: Math.min(differenceInCalendarMonths(end, start), 5),
},
};
const normalTimeAxis: HaECOption = {
const normalTimeAxis: ECOption = {
xAxis: {
type: "time",
min: start,
@@ -156,7 +154,7 @@ export function getCommonOptions(
},
};
const options: HaECOption = {
const options: ECOption = {
...(suggestedPeriod === "month" ? monthTimeAxis : normalTimeAxis),
yAxis: {
type: "value",
@@ -181,7 +179,7 @@ export function getCommonOptions(
},
tooltip: {
trigger: "axis",
formatter: (params: TopLevelFormatterParams) => {
formatter: (params: TopLevelFormatterParams): string => {
// trigger: "axis" gives an array of params, but "item" gives a single param
if (Array.isArray(params)) {
const mainItems: CallbackDataParams[] = [];
@@ -193,7 +191,7 @@ export function getCommonOptions(
mainItems.push(param);
}
});
const sections = [mainItems, compareItems]
return [mainItems, compareItems]
.map((items) =>
formatTooltip(
items,
@@ -206,12 +204,8 @@ export function getCommonOptions(
formatTotal
)
)
.filter((s): s is TemplateResult => s !== nothing);
if (sections.length === 0) return nothing;
return html`${sections.map(
(section, i) =>
html`${i > 0 ? html`<br /><br />` : nothing}${section}`
)}`;
.filter(Boolean)
.join("<br><br>");
}
return formatTooltip(
[params],
@@ -238,9 +232,9 @@ function formatTooltip(
showCompareYear: boolean,
unit?: string,
formatTotal?: (total: number) => string
): TemplateResult | typeof nothing {
) {
if (!params[0]?.value) {
return nothing;
return "";
}
// displayX may be shifted from the period start (see EnergyDataPoint);
// originalStart has the real date for display. Gap-filled entries lack it.
@@ -264,50 +258,43 @@ function formatTooltip(
period += ` ${formatTime(addHours(date, 1), locale, config)}`;
}
}
const title = `<h4 style="text-align: center; margin: 0;">${period}</h4>`;
let sumPositive = 0;
let countPositive = 0;
let sumNegative = 0;
let countNegative = 0;
const rows: TemplateResult[] = [];
for (const param of params) {
const y = param.value?.[1] as number;
const value = formatNumber(
y,
locale,
y < 0.1 ? { maximumFractionDigits: 3 } : undefined
);
if (value === "0") {
continue;
}
if (param.componentSubType === "bar") {
if (y > 0) {
sumPositive += y;
countPositive++;
} else {
sumNegative += y;
countNegative++;
const values = params
.map((param) => {
const y = param.value?.[1] as number;
const value = formatNumber(
y,
locale,
y < 0.1 ? { maximumFractionDigits: 3 } : undefined
);
if (value === "0") {
return false;
}
}
rows.push(
html`<ha-chart-tooltip-marker
.color=${String(param.color ?? "")}
></ha-chart-tooltip-marker>
${param.seriesName}:
<div style="direction:ltr; display: inline;">${value} ${unit}</div>`
);
if (param.componentSubType === "bar") {
if (y > 0) {
sumPositive += y;
countPositive++;
} else {
sumNegative += y;
countNegative++;
}
}
return `${param.marker} ${filterXSS(param.seriesName!)}: <div style="direction:ltr; display: inline;">${value} ${unit}</div>`;
})
.filter(Boolean);
let footer = "";
if (sumPositive !== 0 && countPositive > 1 && formatTotal) {
footer += `<br><b>${formatTotal(sumPositive)}</b>`;
}
if (rows.length === 0) {
return nothing;
if (sumNegative !== 0 && countNegative > 1 && formatTotal) {
footer += `<br><b>${formatTotal(sumNegative)}</b>`;
}
return html`<h4 style="text-align: center; margin: 0;">${period}</h4>
${rows.map(
(row, i) => html`${i > 0 ? html`<br />` : nothing}${row}`
)}${sumPositive !== 0 && countPositive > 1 && formatTotal
? html`<br /><b>${formatTotal(sumPositive)}</b>`
: nothing}${sumNegative !== 0 && countNegative > 1 && formatTotal
? html`<br /><b>${formatTotal(sumNegative)}</b>`
: nothing}`;
return values.length > 0 ? `${title}${values.join("<br>")}${footer}` : "";
}
function getDatapointX(datapoint: NonNullable<LineSeriesOption["data"]>[0]) {
@@ -44,7 +44,7 @@ import {
getCompareTransform,
} from "./common/energy-chart-options";
import { storage } from "../../../../common/decorators/storage";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { ECOption } from "../../../../resources/echarts/echarts";
import { formatNumber } from "../../../../common/number/format_number";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
@@ -216,7 +216,7 @@ export class HuiEnergyDevicesDetailGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): HaECOption => {
): ECOption => {
const commonOptions = getCommonOptions(
start,
end,
@@ -9,10 +9,10 @@ import type { BarSeriesOption, PieSeriesOption } from "echarts/charts";
import { PieChart } from "echarts/charts";
import type { ECElementEvent } from "echarts/types/dist/shared";
import type { PieDataItemOption } from "echarts/types/src/chart/pie/PieSeries";
import { filterXSS } from "../../../../common/util/xss";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
import "../../../../components/chart/ha-chart-tooltip-marker";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
@@ -30,7 +30,7 @@ import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyDevicesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { ECOption } from "../../../../resources/echarts/echarts";
import "../../../../components/ha-card";
import { fireEvent } from "../../../../common/dom/fire_event";
import { measureTextWidth } from "../../../../util/text";
@@ -198,28 +198,24 @@ export class HuiEnergyDevicesGraphCard
`;
}
private _renderTooltip = (params: any) => {
const deviceName = this._getDeviceName(params.name);
private _renderTooltip(params: any) {
const deviceName = filterXSS(this._getDeviceName(params.name));
const title = `<h4 style="text-align: center; margin: 0;">${deviceName}</h4>`;
const value = `${formatNumber(
params.value[0] as number,
this.hass.locale,
params.value < 0.1 ? { maximumFractionDigits: 3 } : undefined
)} kWh ${params.percent ? `(${params.percent} %)` : ""}`;
return html`<h4 style="text-align: center; margin: 0;">${deviceName}</h4>
<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${params.seriesName}:
<div style="direction:ltr; display: inline;">${value}</div>`;
};
return `${title}${params.marker} ${params.seriesName}: <div style="direction:ltr; display: inline;">${value}</div>`;
}
private _createOptions = memoizeOne(
(
data: (BarSeriesOption | PieSeriesOption)[],
chartType: "bar" | "pie",
legendData: typeof this._legendData
): HaECOption => {
const options: HaECOption = {
): ECOption => {
const options: ECOption = {
grid: {
top: 5,
left: 5,
@@ -229,7 +225,7 @@ export class HuiEnergyDevicesGraphCard
},
tooltip: {
show: true,
formatter: this._renderTooltip,
formatter: this._renderTooltip.bind(this),
},
xAxis: { show: false },
yAxis: { show: false },
@@ -35,7 +35,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { ECOption } from "../../../../resources/echarts/echarts";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -177,7 +177,7 @@ export class HuiEnergyGasGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): HaECOption =>
): ECOption =>
getCommonOptions(
start,
end,
@@ -37,7 +37,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { ECOption } from "../../../../resources/echarts/echarts";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -65,7 +65,7 @@ export class HuiEnergySolarGraphCard
};
}
@state() private _chartData: (BarSeriesOption | LineSeriesOption)[] = [];
@state() private _chartData: ECOption["series"][] = [];
@state() private _yAxisFractionDigits = 1;
@@ -175,7 +175,7 @@ export class HuiEnergySolarGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): HaECOption =>
): ECOption =>
getCommonOptions(
start,
end,
@@ -213,7 +213,7 @@ export class HuiEnergySolarGraphCard
}
}
const datasets: (BarSeriesOption | LineSeriesOption)[] = [];
const datasets: ECOption["series"] = [];
const computedStyles = getComputedStyle(this);
@@ -6,7 +6,10 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption } from "echarts/charts";
import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
import type {
TooltipOption,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
@@ -40,7 +43,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { ECOption } from "../../../../resources/echarts/echarts";
const colorPropertyMap = {
to_grid: "--energy-grid-return-color",
@@ -193,7 +196,7 @@ export class HuiEnergyUsageGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): HaECOption => {
): ECOption => {
const commonOptions = getCommonOptions(
start,
end,
@@ -206,22 +209,15 @@ export class HuiEnergyUsageGraphCard
false,
yAxisFractionDigits
);
const tooltip = commonOptions.tooltip;
const baseFormatter =
tooltip &&
!Array.isArray(tooltip) &&
typeof tooltip.formatter === "function"
? tooltip.formatter
: undefined;
const options: HaECOption = {
const options: ECOption = {
...commonOptions,
tooltip: {
...commonOptions.tooltip,
formatter: (params: TopLevelFormatterParams) => {
formatter: (params: TopLevelFormatterParams): string => {
if (!Array.isArray(params)) {
return nothing;
return "";
}
const sorted = [...params].sort((a, b) => {
params.sort((a, b) => {
const aValue = (a.value as number[])?.[1];
const bValue = (b.value as number[])?.[1];
if (aValue > 0 && bValue < 0) {
@@ -235,7 +231,9 @@ export class HuiEnergyUsageGraphCard
}
return a.componentIndex - b.componentIndex;
});
return baseFormatter ? baseFormatter(sorted) : nothing;
return (
(commonOptions.tooltip as TooltipOption)?.formatter as any
)?.(params);
},
},
};
@@ -34,7 +34,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { ECOption } from "../../../../resources/echarts/echarts";
import { formatNumber } from "../../../../common/number/format_number";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -177,7 +177,7 @@ export class HuiEnergyWaterGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): HaECOption =>
): ECOption =>
getCommonOptions(
start,
end,
@@ -24,7 +24,7 @@ import type { LovelaceCard } from "../../types";
import type { PowerSourcesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import { getCommonOptions, fillLineGaps } from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { ECOption } from "../../../../resources/echarts/echarts";
import { hex2rgb } from "../../../../common/color/convert-color";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
@@ -148,7 +148,7 @@ export class HuiPowerSourcesGraphCard
compareEnd: Date | undefined,
legendData: CustomLegendOption["data"] | undefined,
yAxisFractionDigits: number
): HaECOption => ({
): ECOption => ({
...getCommonOptions(
start,
end,
@@ -1,10 +1,9 @@
import { consume } from "@lit/context";
import { mdiAlertCircle, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ConditionListenersController } from "../../../../common/controllers/condition-listeners-controller";
import "../../../../components/ha-alert";
import "../../../../components/ha-svg-icon";
import { HaRowItem } from "../../../../components/item/ha-row-item";
import type { HomeAssistant } from "../../../../types";
@@ -29,15 +28,17 @@ const STATE_ICONS: Record<VisibilityState, string> = {
/**
* @element ha-visibility-status
* @extends {HaRowItem}
*
* @summary
* Alert banner that surfaces the live visibility result for a set of
* lovelace conditions.
* Row-style banner that surfaces the live visibility result for a set of
* lovelace conditions. Replaces the static explanation alert at the top of
* card / section / badge / conditional-card visibility editors.
*
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state (reflected for styling).
*/
@customElement("ha-visibility-status")
export class HaVisibilityStatus extends LitElement {
export class HaVisibilityStatus extends HaRowItem {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
@@ -47,7 +48,7 @@ export class HaVisibilityStatus extends LitElement {
@consume({ context: conditionsEntityContext, subscribe: true })
private _entityContext?: ConditionsEntityContext;
@property()
@property({ reflect: true })
public state: VisibilityState = "visible";
private _listeners = new ConditionListenersController(this);
@@ -70,27 +71,23 @@ export class HaVisibilityStatus extends LitElement {
}
}
public render() {
protected override _renderInner(): TemplateResult {
return html`
<ha-alert
.alertType=${this.state === "visible"
? "success"
: this.state === "hidden"
? "warning"
: "error"}
>
<ha-svg-icon slot="icon" .path=${STATE_ICONS[this.state]}></ha-svg-icon>
<div class="headline">
<div part="start" class="start">
<ha-svg-icon .path=${STATE_ICONS[this.state]}></ha-svg-icon>
</div>
<div part="content" class="content">
<div part="headline" class="headline">
${this.hass?.localize(
`ui.panel.lovelace.editor.condition-editor.visibility_status.${this.state}.headline`
)}
</div>
<div class="supporting">
<div part="supporting-text" class="supporting">
${this.hass?.localize(
`ui.panel.lovelace.editor.condition-editor.visibility_status.${this.state}.supporting${(this.conditions?.length ?? 0) === 0 ? "_empty" : ""}`
)}
</div>
</ha-alert>
</div>
`;
}
@@ -120,13 +117,37 @@ export class HaVisibilityStatus extends LitElement {
static styles: CSSResultGroup = [
HaRowItem.styles,
css`
ha-alert {
:host {
display: block;
border-radius: var(--ha-border-radius-xl);
transition: background-color var(--ha-animation-duration-normal)
ease-in-out;
}
.base {
padding: var(--ha-space-4);
}
:host([state="visible"]) {
background-color: var(--ha-color-fill-success-quiet-resting);
--visibility-status-color: var(--ha-color-on-success-normal);
}
:host([state="hidden"]) {
background-color: var(--ha-color-fill-warning-quiet-resting);
--visibility-status-color: var(--ha-color-on-warning-normal);
}
:host([state="invalid"]) {
background-color: var(--ha-color-fill-danger-quiet-resting);
--visibility-status-color: var(--ha-color-on-danger-normal);
}
.start {
align-self: start;
}
.start ha-svg-icon {
color: var(--visibility-status-color);
--mdc-icon-size: 24px;
}
.headline {
font-weight: var(--ha-font-weight-medium);
margin-bottom: var(--ha-space-1);
white-space: normal;
}
`,
];
-8
View File
@@ -67,14 +67,6 @@ export type ECOption = ComposeOption<
| SunburstSeriesOption
>;
export type {
HaECOption,
HaECSeries,
HaECSeriesItem,
HaTooltipOption,
LitTooltipFormatter,
} from "./ha-ec-option";
// Register the required components
echarts.use([
BarChart,
-33
View File
@@ -1,33 +0,0 @@
import type { TemplateResult, nothing } from "lit";
import type { TooltipOption } from "echarts/types/dist/shared";
import type { ECOption } from "./echarts";
export type LitTooltipFormatter<P = any> = (
params: P,
ticket?: string
) => TemplateResult | typeof nothing | null | undefined;
export type HaTooltipOption = Omit<TooltipOption, "formatter"> & {
formatter?: string | LitTooltipFormatter;
};
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
readonly unknown[]
>;
/** Single series item with optional Lit tooltip formatter */
export type HaECSeriesItem = Omit<RawSeriesOption, "tooltip"> & {
tooltip?: HaTooltipOption;
};
/** Series array passed to ha-chart-base `.data` */
export type HaECSeries = HaECSeriesItem[];
export type HaECOption = {
[K in keyof ECOption]: K extends "tooltip"
? HaTooltipOption | HaTooltipOption[] | undefined
: K extends "series"
? HaECSeriesItem | HaECSeriesItem[] | undefined
: ECOption[K];
};
+10 -2
View File
@@ -5648,6 +5648,15 @@
"run_action_error": "Error running action",
"run_action_success": "Action ran successfully",
"duplicate": "[%key:ui::common::duplicate%]",
"convert": "Convert to…",
"convert_dialog": {
"title": "Convert action",
"warning": "Compatible settings are carried over; anything else is removed. You can undo if you change your mind.",
"convert": "Convert",
"mode_current": "Convert current action",
"mode_new": "Convert into a new action",
"no_matches": "No matching actions"
},
"re_order": "[%key:ui::panel::config::automation::editor::triggers::re_order%]",
"rename": "[%key:ui::panel::config::automation::editor::triggers::rename%]",
"cut": "[%key:ui::panel::config::automation::editor::triggers::cut%]",
@@ -6319,8 +6328,7 @@
"resend_confirm_email": "Resend confirmation email",
"clicked_confirm": "I opened the confirmation link",
"confirm_email": "Check the email we just sent to {email} and open the confirmation link to continue.",
"account_created": "Account created! Check your email for instructions on how to activate your account.",
"verification_email_sent": "Verification email sent. Check your inbox for instructions on how to activate your account."
"account_created": "Account created! Check your email for instructions on how to activate your account."
},
"account": {
"download_support_package": "Download support package",
@@ -1,38 +0,0 @@
import { html, nothing } from "lit";
import { describe, expect, it } from "vitest";
import type { LitTooltipFormatter } from "../../../src/resources/echarts/echarts";
import { wrapLitTooltipFormatter } from "../../../src/components/chart/lit-tooltip-formatter";
describe("wrapLitTooltipFormatter", () => {
it("renders TemplateResult into a stable container", () => {
const formatter = () => html`<b>Hello</b>`;
const wrapped = wrapLitTooltipFormatter(formatter);
const first = wrapped({});
const second = wrapped({});
expect(first).toBe(second);
expect(first?.tagName).toBe("DIV");
expect(first?.style.display).toBe("contents");
expect(first?.textContent).toContain("Hello");
});
it("returns null for nothing, null, and undefined", () => {
const returnNothing: LitTooltipFormatter = () => nothing;
expect(wrapLitTooltipFormatter(returnNothing)({})).toBeNull();
expect(wrapLitTooltipFormatter(() => null)({})).toBeNull();
expect(wrapLitTooltipFormatter(() => undefined)({})).toBeNull();
});
it("returns the same wrapped function for the same formatter", () => {
const formatter = () => html`x`;
expect(wrapLitTooltipFormatter(formatter)).toBe(
wrapLitTooltipFormatter(formatter)
);
});
it("does not double-wrap an already wrapped formatter", () => {
const formatter = () => html`x`;
const wrapped = wrapLitTooltipFormatter(formatter);
expect(wrapLitTooltipFormatter(wrapped)).toBe(wrapped);
});
});
+580 -580
View File
File diff suppressed because it is too large Load Diff