Compare commits

..
24 changed files with 718 additions and 885 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
24.22.3
24.22.2
+19
View File
@@ -0,0 +1,19 @@
import type { ReactiveElement } from "lit";
import { ContextSubscriptionController } from "../../../src/common/decorators/consume";
import { uiContext } from "../../../src/data/context";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// A demo that provides its own hass hides the gallery theme from its children,
// so pass the gallery light or dark mode on to it.
export const followGalleryTheme = (
host: ReactiveElement,
hass: MockHomeAssistant
) =>
new ContextSubscriptionController(host, uiContext, (ui) => {
if (ui) {
hass.updateHass({
themes: ui.themes,
selectedTheme: ui.selectedTheme,
});
}
});
@@ -10,6 +10,7 @@ import type {
} from "../../../../src/data/history";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import { followGalleryTheme } from "../../data/follow-gallery-theme";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
@@ -166,6 +167,7 @@ export class DemoStateHistoryCharts extends LitElement {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
followGalleryTheme(this, hass);
hass.addEntities(ENTITIES);
}
@@ -1,7 +1,9 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import type { ChartSelectMode } from "../../../../src/components/chart/ha-chart-base";
import "../../../../src/components/chart/statistics-chart";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import type {
Statistics,
@@ -11,7 +13,9 @@ import type {
} from "../../../../src/data/recorder";
import { StatisticMeanType } from "../../../../src/data/recorder";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import type { HomeAssistant } from "../../../../src/types";
import { followGalleryTheme } from "../../data/follow-gallery-theme";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
@@ -108,6 +112,64 @@ interface Example {
height?: string;
}
interface TimeRange {
start: number;
end: number;
}
interface RangeExample {
id: string;
title: string;
description: string;
statistics: Statistics;
statTypes: StatisticType[];
chartType: "line" | "bar";
unit: string;
selectMode: ChartSelectMode;
hint: string;
}
const RANGE_EXAMPLES: RangeExample[] = [
{
id: "point",
title: "Point selection",
description:
"With select-mode set to point, a click selects the hour that contains it, and everything outside fades. Dragging still zooms.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
selectMode: "point",
hint: "Click on a bar to select its hour.",
},
{
id: "range-bar",
title: "Range selection on bars",
description:
"With select-mode set to range, dragging selects a time range instead of zooming, so several bars can be selected at once. A click still selects one bar.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
selectMode: "range",
hint: "Click or drag on the chart to select one or more bars.",
},
{
id: "range",
title: "Range selection on lines",
description: "The same on a line chart.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
selectMode: "range",
hint: "Click or drag on the chart to select a period.",
},
];
const formatRange = (range: TimeRange) =>
`${new Date(range.start).toLocaleString()} to ${new Date(range.end).toLocaleString()}`;
const EXAMPLES: Example[] = [
{
title: "Sum as bars",
@@ -159,17 +221,20 @@ const EXAMPLES: Example[] = [
export class DemoStatisticsChart extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _selections: Record<string, TimeRange | undefined> = {};
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
followGalleryTheme(this, hass);
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
return html`${EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
@@ -191,7 +256,66 @@ export class DemoStatisticsChart extends LitElement {
</div>
</ha-card>
`
);
)}
${RANGE_EXAMPLES.map((example) => this._renderRangeExample(example))}`;
}
private _renderRangeExample(example: RangeExample) {
const selection = this._selections[example.id];
return html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<statistics-chart
.statisticsData=${example.statistics}
.metadata=${METADATA}
.names=${NAMES}
.statTypes=${example.statTypes}
.chartType=${example.chartType}
period="hour"
.unit=${example.unit}
.startTime=${START}
.endTime=${END}
.highlight=${selection}
.clickForMoreInfo=${false}
data-example=${example.id}
.selectMode=${example.selectMode}
hide-legend
@chart-select=${this._rangeSelected}
></statistics-chart>
<div class="selection">
${
selection
? html`
<span>${formatRange(selection)}</span>
<ha-button
appearance="plain"
size="s"
data-example=${example.id}
@click=${this._clearSelection}
>
Clear
</ha-button>
`
: html`<span class="hint">${example.hint}</span>`
}
</div>
</div>
</ha-card>
`;
}
private _rangeSelected(ev: HASSDomEvent<HASSDomEvents["chart-select"]>) {
const id = (ev.currentTarget as HTMLElement).dataset.example!;
this._selections = {
...this._selections,
[id]: ev.detail,
};
}
private _clearSelection(ev: Event) {
const id = (ev.currentTarget as HTMLElement).dataset.example!;
this._selections = { ...this._selections, [id]: undefined };
}
static styles = css`
@@ -202,6 +326,20 @@ export class DemoStatisticsChart extends LitElement {
p {
margin-top: 0;
}
.selection {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-2);
min-height: 48px;
margin-top: var(--ha-space-2);
padding: 0 var(--ha-space-2) 0 var(--ha-space-4);
border-radius: var(--ha-border-radius-lg);
background-color: var(--secondary-background-color);
}
.hint {
color: var(--secondary-text-color);
}
`;
}
+6 -6
View File
@@ -79,7 +79,7 @@
"@swc/helpers": "0.5.24",
"@tsparticles/engine": "4.5.3",
"@tsparticles/preset-links": "4.5.3",
"@versatiles/style": "9.1.0",
"@versatiles/style": "9.0.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.204.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -109,7 +109,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.17.0",
"marked": "18.2.0",
"marked": "18.1.3",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -153,7 +153,7 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.65.0",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.3.2",
"@rspack/core": "2.3.1",
"@rspack/dev-server": "2.2.2",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "7.0.1",
@@ -214,8 +214,8 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.72.2",
"vite": "8.3.8",
"typescript-eslint": "8.72.1",
"vite": "8.3.7",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.1.0",
"webpack-stats-plugin": "1.1.3",
@@ -224,6 +224,6 @@
},
"packageManager": "[email protected]",
"volta": {
"node": "24.22.3"
"node": "24.22.2"
}
}
+248 -235
View File
File diff suppressed because it is too large. Load diff
@@ -89,19 +89,10 @@ export class LegendFitController {
private _fit() {
const list = this._element;
if (!list) {
const rowWidth = list?.getBoundingClientRect().width;
if (!list || !rowWidth) {
return;
}
const style = getComputedStyle(list);
const rowWidth = parseFloat(style.width);
// Bounding rects include transforms, such as a dialog scaling in, which
// never resize the list, so measure in layout pixels
const scale = list.getBoundingClientRect().width / rowWidth;
if (!rowWidth || !scale) {
return;
}
const layoutWidth = (element: Element) =>
element.getBoundingClientRect().width / scale;
const children = Array.from(list.children);
const more = children.find((child) =>
child.classList.contains("legend-more")
@@ -116,7 +107,9 @@ export class LegendFitController {
}
this._more = more;
}
const widths = children.filter((child) => child !== more).map(layoutWidth);
const widths = children
.filter((child) => child !== more)
.map((child) => child.getBoundingClientRect().width);
// Values resize the items on every state update. Fitting each item at its
// widest since the last resize keeps the more button from flickering.
if (rowWidth !== this._rowWidth || widths.length !== this._widths.length) {
@@ -129,9 +122,9 @@ export class LegendFitController {
}
const fit = fitInRows(
this._widths,
more ? layoutWidth(more) : 0,
more?.getBoundingClientRect().width ?? 0,
rowWidth,
parseFloat(style.columnGap) || 0,
parseFloat(getComputedStyle(list).columnGap) || 0,
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
);
if (fit !== this.fit) {
+5 -6
View File
@@ -19,7 +19,6 @@ interface MinMaxFrame {
maxPoint: Point;
maxX: number;
maxY: number;
lastX: number;
gapPoint: Point | undefined;
}
@@ -69,7 +68,6 @@ function newFrame(
maxPoint: point,
maxX: x,
maxY: y,
lastX: x,
gapPoint,
};
}
@@ -176,7 +174,6 @@ export function downSampleLineData<
if (!frame) {
frames.set(frameIndex, newFrame(point, x, y, undefined));
} else if (frame.hasValue) {
frame.lastX = x;
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
@@ -192,7 +189,6 @@ export function downSampleLineData<
} else {
// the frame held nothing but a marker so far
frame.hasValue = true;
frame.lastX = x;
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
@@ -217,8 +213,11 @@ export function downSampleLineData<
if (frame.gapPoint !== undefined) {
// A marker followed by a value in its own frame is a gap that closed
// within one frame, which is about one device pixel: too narrow to show.
// That value may be neither min nor max, so check the frame's last x.
if (Number(getPointData(frame.gapPoint)[0]) >= frame.lastX) {
// The kept points are exactly min and max, so comparing against the
// later of the two catches that without any work on the ingest path. A
// marker-only frame compares against its own x and always passes.
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
result.push(frame.gapPoint as T);
}
}
+189 -4
View File
@@ -7,7 +7,10 @@ import {
mdiRestart,
} from "@mdi/js";
import { differenceInMinutes } from "date-fns";
import type { DataZoomComponentOption } from "echarts/components";
import type {
DataZoomComponentOption,
MarkAreaComponentOption,
} from "echarts/components";
import type { EChartsType } from "echarts/core";
import type {
ECElementEvent,
@@ -90,14 +93,29 @@ export const DEFAULT_CHART_WIDTH = 500;
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
const CLICK_DRAG_TOLERANCE = 4;
// The selection is shown by the mask, not by the brush
const BRUSH_STYLE = {
color: "rgba(0, 0, 0, 0)",
borderColor: "rgba(0, 0, 0, 0)",
};
export type ZoomSliderMode = "auto" | "always" | "never";
export type ChartSelectMode = "none" | "point" | "range";
const DEFERRED_PROPS = [
"options",
"data",
"_hiddenDatasets",
"_isZoomed",
"highlight",
] as const;
const MASK_OPACITY = 0.7;
export interface ChartTimeRange {
start: number;
end: number;
}
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
@@ -176,6 +194,18 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@property({ attribute: "grow-for-zoom-slider", type: Boolean })
public growForZoomSlider = false;
// "point" selects on click, "range" also selects on drag instead of zooming
@property({ attribute: "select-mode" })
public selectMode: ChartSelectMode = "none";
// Everything outside this time range fades
@property({ attribute: false }) public highlight?: ChartTimeRange;
// Rounds a selection to the periods of the data, or drops it
@property({ attribute: false }) public snapSelection?: (
range: ChartTimeRange
) => ChartTimeRange | undefined;
// extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@@ -201,6 +231,14 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@state() private _isZoomed = false;
private _pointerDownX?: number;
private _selectingRange?: ChartTimeRange;
private _maskColor = "";
private _maskShown = false;
// Separate from _isZoomed, whose touch handling reruns graph layouts
@state() private _isGraphRoamed = false;
@@ -454,6 +492,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (!this.chart) {
return;
}
if (changedProps.has("selectMode")) {
this._updateSelectMode();
}
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
return;
@@ -499,6 +540,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
) {
this._disposeSonification();
}
} else if (changedProps.has("highlight")) {
this._updateMask();
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
@@ -888,6 +931,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const style = getComputedStyle(this);
echarts.registerTheme("custom", this._createTheme(style));
this._maskColor =
style.getPropertyValue("--card-background-color").trim() ||
style.getPropertyValue("--primary-background-color").trim();
this.chart = echarts.init(this._chartContainer!, "custom");
this._dragToZoom = false;
@@ -970,6 +1016,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._legendItemCount = this._getLegendItems()?.length;
this._updateSankeyRoam();
this._updateDragToZoom();
if (this.selectMode !== "none") {
this._updateSelectMode();
}
if (zoomStart !== 0 || zoomEnd !== 100) {
this.zoom(zoomStart, zoomEnd, true);
}
@@ -1021,7 +1070,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
// A chart can switch type (e.g. pie and bar) without being rebuilt
private _updateDragToZoom() {
const active = !this._touchInput && this._hasZoomableXAxis();
const active =
!this._touchInput &&
this._hasZoomableXAxis() &&
this.selectMode !== "range";
if (!this.chart || active === this._dragToZoom) {
return;
}
@@ -1033,6 +1085,127 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
});
}
private _snapRange(range: ChartTimeRange) {
return this.snapSelection ? this.snapSelection(range) : range;
}
private _getMaskArea(): MarkAreaComponentOption | undefined {
const range = this._selectingRange ?? this.highlight;
if (!range) {
return undefined;
}
this._maskShown = true;
return {
silent: true,
z: 10,
itemStyle: {
color: this._maskColor,
opacity: MASK_OPACITY,
},
data: [
[{ xAxis: -Infinity }, { xAxis: range.start }],
[{ xAxis: range.end }, { xAxis: Infinity }],
],
};
}
// Only the first series carries the mask, merged in without resending data
private _updateMask() {
this.chart?.setOption({
series: [{ markArea: this._getMaskArea() ?? { data: [] } }],
});
}
private _handleSelectPointerDown = (e: ECElementEvent) => {
this._pointerDownX = e.offsetX;
};
private _handleSelectClick = (e: ECElementEvent) => {
const point = [e.offsetX, e.offsetY];
// A drag that ends on the plot is not a click
const dragged =
this._pointerDownX !== undefined &&
Math.abs(e.offsetX - this._pointerDownX) > CLICK_DRAG_TOLERANCE;
if (dragged || !this.chart?.containPixel("grid", point)) {
return;
}
const [x] = this.chart.convertFromPixel("grid", point) as number[];
const selection = this._snapRange({ start: x, end: x });
if (selection) {
fireEvent(this, "chart-select", selection);
}
};
private _handleBrush = (e: any) => {
const range = e.areas?.[0]?.coordRange as number[] | undefined;
if (range) {
this._selectingRange = this._snapRange({
start: range[0],
end: range[1],
});
this._updateMask();
}
};
private _handleBrushEnd = (e: any) => {
const range = e.areas?.[0]?.coordRange as number[] | undefined;
this.chart?.dispatchAction({ type: "brush", areas: [] });
this._selectingRange = undefined;
this._updateMask();
const selection =
range && this._snapRange({ start: range[0], end: range[1] });
if (selection) {
fireEvent(this, "chart-select", selection);
}
};
private _updateSelectMode() {
if (!this.chart) {
return;
}
const zr = this.chart.getZr();
zr.off("mousedown", this._handleSelectPointerDown);
zr.off("click", this._handleSelectClick);
this.chart.off("brush", this._handleBrush);
this.chart.off("brushEnd", this._handleBrushEnd);
if (this.selectMode !== "none") {
zr.on("mousedown", this._handleSelectPointerDown);
zr.on("click", this._handleSelectClick);
}
const selectRange = this.selectMode === "range";
if (selectRange) {
this.chart.on("brush", this._handleBrush);
this.chart.on("brushEnd", this._handleBrushEnd);
this.chart.setOption({
brush: {
xAxisIndex: 0,
// Only measures the range, the mask fades what is outside
seriesIndex: [],
transformable: false,
brushStyle: BRUSH_STYLE,
},
});
}
const brushCursor = {
type: "takeGlobalCursor",
key: "brush",
brushOption: {
brushType: selectRange ? "lineX" : false,
brushMode: "single",
brushStyle: BRUSH_STYLE,
},
};
// Brush and drag to zoom share the global cursor, the last one taken wins
if (selectRange) {
this._updateDragToZoom();
this.chart.dispatchAction(brushCursor);
} else {
this.chart.dispatchAction(brushCursor);
this._dragToZoom = false;
this._updateDragToZoom();
}
}
private _hasZoomableXAxis() {
const xAxis = ensureArray(this.options?.xAxis)?.[0] as
XAXisOption | undefined;
@@ -1475,7 +1648,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _getSeries() {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
XAXisOption | undefined;
const series = ensureArray(this.data).map((s) => {
const maskArea = this._getMaskArea();
const series = ensureArray(this.data).map((s, index) => {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
@@ -1513,6 +1687,16 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
} as HaECSeriesItem;
}
}
// ECharts merges series by id and would keep a mask left out here
if (this._maskShown && (s.type === "line" || s.type === "bar")) {
result = {
...result,
markArea:
index === 0 && maskArea
? maskArea
: ((s as LineSeriesOption).markArea ?? { data: [] }),
} as HaECSeriesItem;
}
return processSeriesTooltipFormatter(result);
});
return series as ECOption["series"];
@@ -1762,7 +1946,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
// it, so a quick drag out would lose the release and its selection
if (
ev.type === "pointerdown" &&
this._dragToZoom &&
(this._dragToZoom || this.selectMode === "range") &&
this._isZoomPress(ev)
) {
this.chart
@@ -2337,6 +2521,7 @@ declare global {
"dataset-hidden": { id: string };
"dataset-unhidden": { id: string };
"chart-click": ECElementEvent;
"chart-select": ChartTimeRange;
"legend-label-click": { id: string };
"chart-zoom": {
start: number;
+50 -2
View File
@@ -5,7 +5,7 @@ import type {
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
@@ -49,7 +49,13 @@ import type {
HomeAssistantUI,
} from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import type {
ChartSelectMode,
ChartTimeRange,
CustomLegendOption,
HaChartBase,
ZoomSliderMode,
} from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
@@ -128,6 +134,16 @@ export class StatisticsChart extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "select-mode" })
public selectMode: ChartSelectMode = "none";
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@property({ attribute: false }) public highlight?: ChartTimeRange;
@query("ha-chart-base") private _chartBase?: HaChartBase;
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
@state() private _legendData: CustomLegendOption["data"];
@@ -244,8 +260,12 @@ export class StatisticsChart extends LitElement {
return html`
<ha-chart-base
.data=${this._chartData}
.highlight=${this.highlight}
.snapSelection=${this._snapSelection}
.options=${this._chartOptions}
.height=${this.height}
.selectMode=${this.selectMode}
.zoomSlider=${this.zoomSlider}
style=${styleMap({ height: this.height })}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
@@ -259,6 +279,34 @@ export class StatisticsChart extends LitElement {
`;
}
public resetZoom() {
this._chartBase?.zoom(0, 100);
}
// A click selects the period that contains it, a drag every period it
// touches
private _snapSelection = (
range: ChartTimeRange
): ChartTimeRange | undefined => {
let start: number | undefined;
let end: number | undefined;
Object.values(this.statisticsData ?? {}).forEach((values) =>
values.forEach((value) => {
const touched =
range.start === range.end
? value.start <= range.start && range.start < value.end
: value.start < range.end && value.end > range.start;
if (touched) {
start = Math.min(start ?? value.start, value.start);
end = Math.max(end ?? value.end, value.end);
}
})
);
return start === undefined || end === undefined
? undefined
: { start, end };
};
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
this._hiddenStats.add(ev.detail.id);
this.requestUpdate("_hiddenStats");
@@ -112,7 +112,7 @@ export class HaTracePathDetails extends LitElement {
initialValue: {
options: [],
showIndices: false,
select: () => true,
select: () => undefined,
fixDuplicateIds: async () => undefined,
updateTriggerId: () => false,
},
-15
View File
@@ -60,18 +60,3 @@ export const validateHassioSession = async (
): Promise<void> => {
await hass.callWS({ type: "supervisor/ingress/validate_session", session });
};
// Extends the session, or replaces it with a new one for the app if it has
// expired. Returns the session to keep using.
export const refreshHassioSession = async (
hass: Pick<HomeAssistant, "callWS">,
slug: string,
session: string
): Promise<string> => {
try {
await validateHassioSession(hass, session);
return session;
} catch (_err: any) {
return createHassioSession(hass, slug);
}
};
+8 -51
View File
@@ -16,7 +16,7 @@ import type { IngressAppInfo } from "../../data/hassio/ingress";
import {
createHassioSession,
fetchIngressAppInfo,
refreshHassioSession,
validateHassioSession,
} from "../../data/hassio/ingress";
import {
showAlertDialog,
@@ -60,10 +60,6 @@ class HaPanelApp extends LitElement {
private _sessionKeepAlive?: number;
private _session?: { slug: string; session: string };
private _sessionRefresh?: Promise<void>;
private _fetchDataTimeout?: number;
private _autoRetryUntil?: number;
@@ -96,22 +92,17 @@ class HaPanelApp extends LitElement {
super.connectedCallback();
window.addEventListener("message", this._handleIframeMessage);
window.addEventListener("resize", this._handleResize);
document.addEventListener("visibilitychange", this._handleVisibility);
document.addEventListener("resume", this._handleVisibility);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("message", this._handleIframeMessage);
window.removeEventListener("resize", this._handleResize);
document.removeEventListener("visibilitychange", this._handleVisibility);
document.removeEventListener("resume", this._handleVisibility);
if (this._sessionKeepAlive) {
clearInterval(this._sessionKeepAlive);
this._sessionKeepAlive = undefined;
}
this._session = undefined;
if (this._fetchDataTimeout) {
clearTimeout(this._fetchDataTimeout);
this._fetchDataTimeout = undefined;
@@ -351,51 +342,17 @@ class HaPanelApp extends LitElement {
if (this._sessionKeepAlive) {
clearInterval(this._sessionKeepAlive);
}
this._session = { slug: addonSlug, session };
this._sessionKeepAlive = window.setInterval(this._refreshSession, 60000);
this._sessionKeepAlive = window.setInterval(async () => {
try {
await validateHassioSession(this.hass, session);
} catch (_err: any) {
session = await createHassioSession(this.hass, addonSlug);
}
}, 60000);
this._addon = addon;
}
// The keep-alive timer does not run while the device sleeps, so the session
// may have expired by the time the page is visible again. Refresh it right
// away instead of leaving the app unreachable until the next tick.
// "resume" only fires in Chromium after a frozen page, other browsers rely
// on "visibilitychange".
private _handleVisibility = () => {
if (!document.hidden) {
this._refreshSession();
}
};
private _refreshSession = (): Promise<void> => {
const current = this._session;
if (!current) {
return Promise.resolve();
}
if (this._sessionRefresh) {
return this._sessionRefresh;
}
this._sessionRefresh = refreshHassioSession(
this.hass,
current.slug,
current.session
)
.then((session) => {
// the panel may have switched to another app meanwhile
if (this._session === current) {
this._session = { slug: current.slug, session };
}
})
.catch((_err: any) => {
// Connection to Home Assistant is gone, the next refresh will retry
})
.finally(() => {
this._sessionRefresh = undefined;
});
return this._sessionRefresh;
};
private async _checkLoaded(ev: Event): Promise<void> {
const iframe = ev.target as HTMLIFrameElement;
@@ -6,10 +6,8 @@ import { consume } from "../../../../../common/decorators/consume";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
import "../../../../../components/animation/ha-fade-in";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-trigger-icon";
import "../../../../../components/item/ha-list-item-option";
@@ -19,7 +17,6 @@ import type { TriggerCondition } from "../../../../../data/automation";
import {
automationTriggerContext,
hasCustomTriggerId,
triggerOptionsResolved,
type AutomationTriggerContext,
type TriggerIdOption,
} from "../../trigger/automation-trigger-id";
@@ -56,33 +53,16 @@ export class HaTriggerCondition extends LitElement {
const selectedIds = (ensureArray(this.condition.id) ?? []).filter(Boolean);
const triggerIdOptions = this._triggers?.options ?? [];
const missingIds = this._missingIds(this.condition.id, triggerIdOptions);
const status = this._triggers?.blueprintStatus;
const resolved = triggerOptionsResolved(this._triggers);
if (!triggerIdOptions.length && !selectedIds.length) {
switch (status) {
case "loading":
return html`<ha-fade-in .delay=${500}>
<ha-spinner size="small"></ha-spinner>
</ha-fade-in>`;
case "failed":
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.blueprint_triggers_unavailable"
);
case "loaded":
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.blueprint_no_trigger_ids"
);
case undefined:
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
);
}
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
);
}
return html`
${
resolved && missingIds.length
missingIds.length
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_triggers"
@@ -126,13 +106,7 @@ export class HaTriggerCondition extends LitElement {
appearance="checkbox"
>
<span slot="start" class="trigger-row-leading">
${
this._triggers?.showIndices
? html`<span class="trigger-index-badge"
>${option.index + 1}</span
>`
: nothing
}
<span class="trigger-index-badge">${option.index + 1}</span>
<ha-trigger-icon
.trigger=${
"trigger" in option.trigger ? option.trigger.trigger : ""
@@ -145,9 +119,7 @@ export class HaTriggerCondition extends LitElement {
)}
</span>
${
option.duplicate ||
hasCustomTriggerId(option) ||
!this._triggers?.showIndices
option.duplicate || hasCustomTriggerId(option)
? html`<span
slot="supporting-text"
class="trigger-id ${option.duplicate ? "duplicate" : ""}"
@@ -180,22 +152,14 @@ export class HaTriggerCondition extends LitElement {
.disabled=${this.disabled}
appearance="checkbox"
>
${
resolved
? html`<span slot="start" class="missing-trigger-badge">
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
<span class="missing-trigger-id">${id}</span>
</span>`
: nothing
}
<span slot="start" class="missing-trigger-badge">
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
<span class="missing-trigger-id">${id}</span>
</span>
<span slot="headline">
${
resolved
? this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
)
: id
}
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
)}
</span>
</ha-list-item-option>
`
@@ -233,7 +197,8 @@ export class HaTriggerCondition extends LitElement {
? ids
: [...ids, id]
: ids.filter((_id) => _id !== id);
if (this._triggers?.select(this.condition, selectedIds)) {
if (this._triggers) {
this._triggers.select(this.condition, selectedIds);
return;
}
fireEvent(this, "value-changed", {
@@ -146,15 +146,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._manualEditor?.resetPastedConfig();
this._updateConfig(config);
},
fetchBlueprintTriggers: async ({ path, input }) => {
const result = await substituteBlueprint(
this.hass,
"automation",
path,
input || {}
);
return normalizeAutomationConfig(result.substituted_config).triggers;
},
});
private _newAutomationId?: string;
@@ -3,14 +3,11 @@ import type { LitElement, ReactiveController } from "lit";
import memoizeOne from "memoize-one";
import type {
AutomationConfig,
BlueprintAutomationConfig,
SidebarConfig,
Trigger,
TriggerCondition,
} from "../../../../data/automation";
import { ContextSubscriptionController } from "../../../../common/decorators/consume";
import { debounce } from "../../../../common/util/debounce";
import { deepEqual } from "../../../../common/util/deep-equal";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { internationalizationContext } from "../../../../data/context";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
@@ -19,15 +16,12 @@ import {
automationTriggerContext,
cleanupRemovedGeneratedTriggerReferences,
cleanupUnusedGeneratedTriggerIds,
getBlueprintTriggerIdOptions,
getTriggerIdOptions,
getExplicitTriggerIds,
isGeneratedTriggerId,
makeDuplicateTriggerIdsUnique,
replaceLeafTrigger,
updateTriggerCondition,
type TriggerIdOption,
type BlueprintTriggersStatus,
} from "./automation-trigger-id";
import { preserveAutomationRowKeys } from "../ha-automation-sortable-list-mixin";
@@ -37,10 +31,6 @@ interface AutomationTriggerControllerOptions {
canEdit: () => boolean;
/** Apply one complete change through the editor's undo and dirty-state handling. */
commit: (config: AutomationConfig) => void;
/** Resolve a blueprint automation's triggers, with its inputs substituted. */
fetchBlueprintTriggers: (
useBlueprint: BlueprintAutomationConfig["use_blueprint"]
) => Promise<Trigger | Trigger[] | undefined>;
}
/**
@@ -53,9 +43,6 @@ interface AutomationTriggerControllerOptions {
*
* The transformation helpers only change configuration. This controller preserves
* the corresponding row keys so an open sidebar continues to track the edited row.
*
* A blueprint automation has no triggers of its own: options come from the
* blueprint's substituted triggers, and conditions in its inputs change themselves.
*/
export class AutomationTriggerController implements ReactiveController {
// Only read in event handlers, so it does not need to rerender the host.
@@ -67,18 +54,6 @@ export class AutomationTriggerController implements ReactiveController {
// changes, so rendering and selection use the same IDs across host updates.
private _getTriggerIdOptions = memoizeOne(getTriggerIdOptions);
private _getBlueprintTriggerIdOptions = memoizeOne(
getBlueprintTriggerIdOptions
);
/** The blueprint use whose triggers were last requested. */
private _blueprintRequested?: BlueprintAutomationConfig["use_blueprint"];
private _blueprintTriggers?: Trigger | Trigger[];
/** "failed" while required inputs are empty. */
private _blueprintStatus: BlueprintTriggersStatus = "loading";
constructor(
private _host: LitElement,
private _options: AutomationTriggerControllerOptions
@@ -103,39 +78,15 @@ export class AutomationTriggerController implements ReactiveController {
_host.addController(this);
}
public hostDisconnected() {
this._fetchBlueprintTriggers.cancel();
this._blueprintRequested = undefined;
}
/** Publish options before descendants render, including after undo or redo. */
public hostUpdate() {
const config = this._options.getConfig();
if (!config) {
return;
}
let options: TriggerIdOption[];
let blueprintStatus: BlueprintTriggersStatus | undefined;
let showIndices = this._provider.value.showIndices;
if ("use_blueprint" in config && config.use_blueprint) {
this._requestBlueprintTriggers(config.use_blueprint);
options = this._getBlueprintTriggerIdOptions(this._blueprintTriggers);
blueprintStatus = this._blueprintStatus;
showIndices = false;
} else {
options = this._getTriggerIdOptions(config.triggers);
}
if (
options !== this._provider.value.options ||
blueprintStatus !== this._provider.value.blueprintStatus ||
showIndices !== this._provider.value.showIndices
) {
this._provider.setValue({
...this._provider.value,
options,
blueprintStatus,
showIndices,
});
const options = this._getTriggerIdOptions(config.triggers);
if (options !== this._provider.value.options) {
this._provider.setValue({ ...this._provider.value, options });
}
}
@@ -187,54 +138,10 @@ export class AutomationTriggerController implements ReactiveController {
return cleaned;
}
/** Request the blueprint's triggers again whenever its path or inputs change. */
private _requestBlueprintTriggers(
useBlueprint: BlueprintAutomationConfig["use_blueprint"]
) {
if (deepEqual(useBlueprint, this._blueprintRequested)) {
return;
}
if (useBlueprint.path !== this._blueprintRequested?.path) {
this._blueprintTriggers = undefined;
}
this._blueprintStatus = "loading";
this._blueprintRequested = useBlueprint;
this._fetchBlueprintTriggers(useBlueprint);
}
private _fetchBlueprintTriggers = debounce(
async (useBlueprint: BlueprintAutomationConfig["use_blueprint"]) => {
let triggers: Trigger | Trigger[] | undefined;
try {
triggers = await this._options.fetchBlueprintTriggers(useBlueprint);
} catch (_err) {
if (useBlueprint === this._blueprintRequested) {
this._blueprintStatus = "failed";
this._host.requestUpdate();
}
return;
}
if (useBlueprint !== this._blueprintRequested) {
return;
}
this._blueprintTriggers = triggers;
this._blueprintStatus = "loaded";
this._host.requestUpdate();
},
500,
true
);
private _selectTriggerIds = (
condition: TriggerCondition,
ids: string[]
): boolean => {
private _selectTriggerIds = (condition: TriggerCondition, ids: string[]) => {
const config = this._options.getConfig();
if (config && "use_blueprint" in config) {
return false;
}
if (!config || !this._options.canEdit()) {
return true;
return;
}
const triggers = assignGeneratedTriggerIds(
config.triggers,
@@ -250,7 +157,6 @@ export class AutomationTriggerController implements ReactiveController {
// Generated IDs that no condition or action references are no longer in use.
// Strip them so the YAML does not accumulate stale identifiers.
this._commit(cleanupUnusedGeneratedTriggerIds(updated));
return true;
};
private _fixDuplicateTriggerIds = async () => {
@@ -38,28 +38,13 @@ export interface TriggerIdOption {
duplicate: boolean;
}
/**
* Whether a blueprint's substituted triggers are known. While "loading" or
* "failed", options keep the last triggers fetched, if any.
*/
export type BlueprintTriggersStatus = "loaded" | "loading" | "failed";
/** Controller-provided options and operations for editors, sidebars, and row summaries. */
export interface AutomationTriggerContext {
options: TriggerIdOption[];
/**
* Left out for an automation with its own triggers. Set for a blueprint
* automation, whose missing IDs can only be flagged once "loaded".
*/
blueprintStatus?: BlueprintTriggersStatus;
/** True while the user is editing a `triggered_by` condition; trigger rows show their index badges only in this state. */
showIndices: boolean;
/**
* Pass the original condition object so identical conditions remain distinguishable.
* Return false when the condition is not one of the automation's conditions, as in
* a blueprint automation, whose conditions live in its inputs.
*/
select: (condition: TriggerCondition, ids: string[]) => boolean;
/** Pass the original condition object so identical conditions remain distinguishable. */
select: (condition: TriggerCondition, ids: string[]) => void;
/** Requests confirmation before migrating duplicate IDs throughout the automation. */
fixDuplicateIds: () => Promise<void>;
/**
@@ -69,13 +54,6 @@ export interface AutomationTriggerContext {
updateTriggerId: (original: Trigger, updated: Trigger) => boolean;
}
/** Whether options list every trigger, so an ID not among them is really missing. */
export const triggerOptionsResolved = (
context: AutomationTriggerContext | undefined
): boolean =>
context?.blueprintStatus === undefined ||
context.blueprintStatus === "loaded";
export const automationTriggerContext =
createContext<AutomationTriggerContext>("automationTriggers");
@@ -162,27 +140,6 @@ export const getTriggerIdOptions = (
});
};
/**
* Build options from a blueprint's substituted triggers. The blueprint owns its
* triggers, so only those with a stored ID can be referenced, and triggers sharing
* an ID are one option: a condition on that ID matches any of them.
*/
export const getBlueprintTriggerIdOptions = (
triggers: Trigger | Trigger[] | undefined
): TriggerIdOption[] => {
const options: TriggerIdOption[] = [];
const seenIds = new Set<string>();
flattenTriggers(triggers).forEach((trigger, index) => {
const id = getTriggerId(trigger);
if (!id || seenIds.has(id)) {
return;
}
seenIds.add(id);
options.push({ id, trigger, index, draft: false, duplicate: false });
});
return options;
};
/**
* Transform leaf triggers recursively, retaining list wrappers and scalar/array shape.
* Copy only changed branches; callbacks must return replacements rather than mutate leaves.
@@ -14,7 +14,6 @@ import type { EntityRegistryEntry } from "../../../../data/entity/entity_registr
import type { HomeAssistant } from "../../../../types";
import {
automationTriggerContext,
triggerOptionsResolved,
type AutomationTriggerContext,
} from "./automation-trigger-id";
@@ -42,7 +41,6 @@ export class HaAutomationTriggerReferences extends LitElement {
const missingIds = selectedIds.filter(
(id) => !options.some((option) => option.id === id)
);
const resolved = triggerOptionsResolved(this._triggers);
if (options.length && !selectedIds.length) {
return html`<span class="trigger-reference no-selection">
@@ -89,21 +87,17 @@ export class HaAutomationTriggerReferences extends LitElement {
</span>
`
),
missingIds.map((id) =>
resolved
? html`
<span class="trigger-reference missing">
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
<span>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
)}
</span>
</span>
`
: html`<span class="trigger-reference">
<span class="trigger-reference-label">${id}</span>
</span>`
missingIds.map(
() => html`
<span class="trigger-reference missing">
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
<span>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
)}
</span>
</span>
`
),
];
}
+8
View File
@@ -15,6 +15,8 @@ import {
DataZoomComponent,
VisualMapComponent,
ToolboxComponent,
MarkAreaComponent,
BrushComponent,
} from "echarts/components";
// Features like Universal Transition and Label Layout
@@ -48,6 +50,8 @@ import type {
GridComponentOption,
DataZoomComponentOption,
VisualMapComponentOption,
MarkAreaComponentOption,
BrushComponentOption,
} from "echarts/components";
import type { ComposeOption } from "echarts/core";
@@ -62,6 +66,8 @@ export type ECOption = ComposeOption<
| GridComponentOption
| DataZoomComponentOption
| VisualMapComponentOption
| MarkAreaComponentOption
| BrushComponentOption
| SankeySeriesOption
| GraphSeriesOption
| SunburstSeriesOption
@@ -91,6 +97,8 @@ echarts.use([
UniversalTransition,
CanvasRenderer,
ToolboxComponent,
MarkAreaComponent,
BrushComponent,
]);
export { LinearGradient };
-2
View File
@@ -6442,8 +6442,6 @@
"label": "Triggered by",
"no_triggers": "There are no triggers in this automation. Add a trigger first.",
"no_triggers_selected": "No triggers selected.",
"blueprint_no_trigger_ids": "This blueprint has no triggers with IDs, so there is nothing to choose.",
"blueprint_triggers_unavailable": "Complete all of the blueprint's required inputs to see its triggers.",
"id": "Trigger",
"missing_trigger": "Missing trigger",
"missing_triggers": "This condition references a trigger that no longer exists. Uncheck it to clear it.",
-17
View File
@@ -264,23 +264,6 @@ describe("downSampleLineData", () => {
]);
});
it("drops a marker followed by a value that is neither min nor max", () => {
// The reading after the gap repeats the one before it, so it loses the
// min/max ties. The series then holds still for hours (#55896).
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 1.2],
[FIXED_EPOCH_MS + 1_000, 1.2],
[FIXED_EPOCH_MS + 1_001, 1.2],
[FIXED_EPOCH_MS + 1_001, null],
[FIXED_EPOCH_MS + 2_000, 1.2],
[FIXED_EPOCH_MS + 30_000, 1.2],
[FIXED_EPOCH_MS + 31_000, 1.2],
];
expect(
downSampleLineData(points, 3).some((point) => point[1] === null)
).toBe(false);
});
it("keeps a marker sharing its x with a value after that value", () => {
// statistics-chart-data.ts ends the line and breaks it at the same x
const points: GappedPoint[] = [
-78
View File
@@ -1,78 +0,0 @@
import { afterEach, assert, describe, it, vi } from "vitest";
import { refreshHassioSession } from "../../src/data/hassio/ingress";
import type { HomeAssistant } from "../../src/types";
interface IngressMessage {
type: string;
slug?: string;
session?: string;
}
describe("Refresh hassio session", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("keeps the session when it is still valid", async () => {
const calls: IngressMessage[] = [];
const hass = {
callWS: async (msg: IngressMessage) => {
calls.push(msg);
return {};
},
} as unknown as Pick<HomeAssistant, "callWS">;
const session = await refreshHassioSession(hass, "my_app", "old-session");
assert.strictEqual(session, "old-session");
assert.deepEqual(calls, [
{ type: "supervisor/ingress/validate_session", session: "old-session" },
]);
});
it("creates a new session for the app when validation fails", async () => {
vi.stubGlobal("document", {});
vi.stubGlobal("location", { protocol: "http:" });
const calls: IngressMessage[] = [];
const hass = {
callWS: async (msg: IngressMessage) => {
calls.push(msg);
if (msg.type === "supervisor/ingress/validate_session") {
throw new Error("Unauthorized");
}
return { session: "new-session" };
},
} as unknown as Pick<HomeAssistant, "callWS">;
const session = await refreshHassioSession(hass, "my_app", "old-session");
assert.strictEqual(session, "new-session");
assert.deepEqual(calls, [
{ type: "supervisor/ingress/validate_session", session: "old-session" },
{ type: "supervisor/ingress/session", slug: "my_app" },
]);
assert.strictEqual(
global.document.cookie,
"ingress_session=new-session;path=/api/hassio_ingress/;SameSite=Strict"
);
});
it("rejects when neither validation nor creation works", async () => {
const hass = {
callWS: async () => {
throw new Error("Connection lost");
},
} as unknown as Pick<HomeAssistant, "callWS">;
const result = await refreshHassioSession(
hass,
"my_app",
"old-session"
).then(
() => "resolved",
() => "rejected"
);
assert.strictEqual(result, "rejected");
});
});
@@ -1,195 +0,0 @@
import { ContextEvent } from "@lit/context";
import { LitElement } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
AutomationConfig,
BlueprintAutomationConfig,
Trigger,
} from "../../../../../src/data/automation";
import { AutomationTriggerController } from "../../../../../src/panels/config/automation/trigger/automation-trigger-controller";
import {
automationTriggerContext,
type AutomationTriggerContext,
} from "../../../../../src/panels/config/automation/trigger/automation-trigger-id";
class TestHost extends LitElement {}
customElements.define("test-automation-trigger-host", TestHost);
declare global {
interface HTMLElementTagNameMap {
"test-automation-trigger-host": TestHost;
}
}
// A stored blueprint automation: only `use_blueprint`, with input values.
const blueprint = (path: string, input: Record<string, unknown> = {}) =>
({ use_blueprint: { path, input } }) as unknown as BlueprintAutomationConfig;
const trigger = (id: string): Trigger => ({
trigger: "event",
event_type: id,
id,
});
/** A fetch whose responses the test resolves or rejects in any order. */
const createFetch = () => {
const pending: {
resolve: (triggers: Trigger[] | undefined) => void;
reject: (err: Error) => void;
}[] = [];
const fetch = vi.fn(
() =>
new Promise<Trigger[] | undefined>((resolve, reject) => {
pending.push({ resolve, reject });
})
);
return { fetch, pending };
};
describe("AutomationTriggerController with a blueprint automation", () => {
let host: TestHost;
let config: AutomationConfig;
let fetch: ReturnType<typeof createFetch>;
let controller: AutomationTriggerController;
// What a condition editor inside the automation receives.
const context = (): AutomationTriggerContext => {
let value: AutomationTriggerContext | undefined;
const child = document.createElement("div");
host.appendChild(child);
child.dispatchEvent(
new ContextEvent(automationTriggerContext, child, (v) => {
value = v;
})
);
child.remove();
return value!;
};
const ids = () => context().options.map((option) => option.id);
// The host's update after the editor's config changes.
const update = (next: AutomationConfig) => {
config = next;
controller.hostUpdate();
};
// Let timers and answered fetches run, then the update they request.
const settle = async () => {
await vi.runAllTimersAsync();
controller.hostUpdate();
};
beforeEach(() => {
vi.useFakeTimers();
// Left out of the document, so only the test runs the host's updates.
host = document.createElement("test-automation-trigger-host");
fetch = createFetch();
config = blueprint("motion.yaml");
controller = new AutomationTriggerController(host, {
getConfig: () => config,
canEdit: () => true,
commit: vi.fn(),
fetchBlueprintTriggers: fetch.fetch,
});
});
afterEach(() => {
controller.hostDisconnected();
vi.useRealTimers();
});
it("offers the IDs of the blueprint's substituted triggers", async () => {
update(blueprint("motion.yaml", { sensor: "binary_sensor.hall" }));
expect(fetch.fetch).toHaveBeenCalledWith({
path: "motion.yaml",
input: { sensor: "binary_sensor.hall" },
});
fetch.pending[0].resolve([trigger("motion_on"), trigger("motion_off")]);
await settle();
expect(ids()).toEqual(["motion_on", "motion_off"]);
});
it("reports the options as loading until the triggers arrive", async () => {
update(blueprint("motion.yaml"));
expect(context().blueprintStatus).toBe("loading");
fetch.pending[0].resolve([trigger("motion_on")]);
await settle();
expect(context().blueprintStatus).toBe("loaded");
});
it("reports a blueprint whose triggers have no IDs as loaded", async () => {
update(blueprint("motion.yaml"));
fetch.pending[0].resolve([{ trigger: "event", event_type: "motion" }]);
await settle();
expect(ids()).toEqual([]);
expect(context().blueprintStatus).toBe("loaded");
});
it("reports a blueprint that never substituted as failed", async () => {
update(blueprint("motion.yaml"));
fetch.pending[0].reject(new Error("Missing input sensor"));
await settle();
expect(ids()).toEqual([]);
expect(context().blueprintStatus).toBe("failed");
});
it("keeps the last triggers listed, as loading, while new inputs load", async () => {
update(blueprint("motion.yaml", { id: "motion_on" }));
fetch.pending[0].resolve([trigger("motion_on")]);
await settle();
update(blueprint("motion.yaml", { id: "motion_detected" }));
expect(ids()).toEqual(["motion_on"]);
expect(context().blueprintStatus).toBe("loading");
await vi.runAllTimersAsync();
fetch.pending[1].resolve([trigger("motion_detected")]);
await settle();
expect(ids()).toEqual(["motion_detected"]);
expect(context().blueprintStatus).toBe("loaded");
});
it("fetches again only when `use_blueprint` changes by value", async () => {
update(blueprint("motion.yaml", { wait: 1 }));
// YAML mode parses a new, equal object when another key is edited.
update({ ...blueprint("motion.yaml", { wait: 1 }), alias: "Hall" });
await vi.runAllTimersAsync();
expect(fetch.fetch).toHaveBeenCalledOnce();
});
it("uses only the response for the latest inputs", async () => {
update(blueprint("motion.yaml", { wait: 1 }));
update(blueprint("motion.yaml", { wait: 2 }));
await vi.runAllTimersAsync();
expect(fetch.fetch).toHaveBeenCalledTimes(2);
fetch.pending[1].resolve([trigger("latest")]);
fetch.pending[0].resolve([trigger("stale")]);
await settle();
expect(ids()).toEqual(["latest"]);
});
it("keeps the last triggers when the inputs fail to substitute", async () => {
update(blueprint("motion.yaml", { wait: 1 }));
fetch.pending[0].resolve([trigger("motion_on")]);
await settle();
update(blueprint("motion.yaml", { wait: undefined }));
await vi.runAllTimersAsync();
fetch.pending[1].reject(new Error("Missing input wait"));
await settle();
expect(ids()).toEqual(["motion_on"]);
expect(context().blueprintStatus).toBe("failed");
});
});
@@ -10,7 +10,6 @@ import {
cleanupRemovedGeneratedTriggerReferences,
cleanupUnusedGeneratedTriggerIds,
GENERATED_TRIGGER_ID_PREFIX,
getBlueprintTriggerIdOptions,
getExplicitTriggerIds,
getTriggerIdOptions,
hasCustomTriggerId,
@@ -26,40 +25,6 @@ const GENERATED_TRIGGER_ID_PATTERN = new RegExp(
);
describe("automation trigger IDs", () => {
it("offers a blueprint's stored trigger IDs once each, without generating any", () => {
const motion: Trigger = {
trigger: "state",
entity_id: "binary_sensor.motion",
id: "motion_on",
};
const triggers = [
motion,
{ trigger: "time_pattern", minutes: "/5" },
{
trigger: "state",
entity_id: "binary_sensor.presence",
id: "motion_on",
},
{ triggers: [{ trigger: "sun", event: "sunset", id: "sunset" }] },
] as Trigger[];
const options = getBlueprintTriggerIdOptions(triggers);
expect(
options.map(({ id, index, draft, duplicate }) => ({
id,
index,
draft,
duplicate,
}))
).toEqual([
{ id: "motion_on", index: 0, draft: false, duplicate: false },
{ id: "sunset", index: 3, draft: false, duplicate: false },
]);
expect(options[0].trigger).toBe(motion);
expect(getBlueprintTriggerIdOptions(undefined)).toEqual([]);
});
it("ignores null trigger entries from incomplete YAML", () => {
const triggers = [
null,