Compare commits

..
Author SHA1 Message Date
Petar Petrov 0ed9333dc2 Keep history line after unavailable gap in downsampled charts 2026-11-14 15:59:23 +02:00
renovate[bot] 2706bd89a1 Update dependency vite to v8.3.8 (#55906)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-14 08:55:55 +02:00
renovate[bot] bd434eeb0d Update dependency @rspack/core to v2.3.2 (#55899)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-13 17:11:21 +02:00
Petar Petrov 16f01e1081 Fix legends fitting as narrow in the card editor preview (#55886) 2026-11-13 14:28:15 +01:00
Dan SpencerandClaude Opus 5.5 050251db23 Offer a blueprint's trigger IDs in Triggered by conditions (#55802)
* Offer a blueprint's trigger IDs in Triggered by conditions

In an automation that uses a blueprint, a Triggered by condition in one
of its inputs listed no triggers, and flagged the IDs it already used as
missing. The editor took the list from the automation's own triggers,
and a blueprint automation has only `use_blueprint`.

For a blueprint automation, fetch the blueprint with its inputs filled
in (`blueprint/substitute`) and list the IDs its triggers set, fetching
again when the inputs change. A trigger without an ID isn't offered,
since the editor can't give one to a blueprint's trigger, and an ID
shared by several triggers is listed once, as the condition matches any
of them.

The condition is in one of the blueprint's inputs, which the trigger
controller doesn't edit, so its `select` returns false there, as
`updateTriggerId` does for a trigger it doesn't own, and the condition
editor saves the pick through `value-changed` instead.

An option shows its trigger number only while trigger rows show theirs
(`showIndices`): the numbers point at trigger rows, and a blueprint
automation has none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Ignore an empty use_blueprint while editing YAML

`use_blueprint:` with no value parses to null, and requesting its
triggers threw on `.path`, aborting the editor's update.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hold back trigger warnings until a blueprint's triggers are known

While a blueprint's triggers load, or when its inputs can't be substituted
yet, a Triggered by condition can't tell a missing ID from one it hasn't
seen. The trigger context now carries optionsStatus: "loaded", "loading" or
"failed". Until it's loaded, the condition and the row's summary list the
selected IDs without the missing-trigger warning or chips. An empty
condition shows "Loading", or a hint to set the blueprint's required inputs
once substitution has failed. After a failure the last triggers stay listed.

use_blueprint is also compared by value, since YAML mode parses a new object
on every edit and an edit elsewhere fetched the triggers again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Address review: spinner, refetch warnings and index badges

- Show a delayed ha-spinner while a blueprint's triggers load, following
  the frontend's loading pattern, instead of visible "Loading" text.
- Mark every request as loading, keeping the last triggers listed, so an
  input change that renames a trigger's ID doesn't read as missing until
  the new triggers arrive.
- Publish showIndices false for a blueprint automation. Switching to YAML
  removes the manual editor's sidebar without reporting it, which left
  the blueprint's condition numbering trigger rows that don't exist.
- Drop inline comments from the blueprint trigger code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Show trigger IDs when trigger rows aren't numbered

A Triggered by condition tells options apart by description, custom ID or
index. In a blueprint automation there are no indices, so two triggers
that describe the same with generated-prefixed IDs looked identical while
selecting different IDs. Without indices, every option's ID is shown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Say when a blueprint's triggers have no IDs

A blueprint whose triggers have no IDs loads with nothing to offer, and
the condition said "Add a trigger first", which can't be done in a
blueprint automation. optionsStatus is now blueprintStatus, left out for
an automation with its own triggers, so the condition can tell the two
apart and say the blueprint's triggers have no IDs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Rename blueprint_triggers_pending to blueprint_triggers_unavailable

It's only shown once substitution has failed, not while loading.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Drop controller tests that cover one-line branches

Keeps the tests for the response race, the statuses and refetching.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 06:26:48 +00:00
renovate[bot] 13128609de Update dependency @versatiles/style to v9.1.0 (#55893)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-13 08:22:31 +02:00
renovate[bot] 0f70813ada Update dependency marked to v18.2.0 (#55894)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-13 08:22:27 +02:00
Matthias 5762da9d22 Refresh the ingress session when the app panel becomes visible (#54863)
The ingress session is kept alive by a 60 s timer only. Timers do not run
while the device sleeps, so after the browser comes back to the foreground
the session may already be expired, and the Supervisor refuses every
request to the app until the next tick. An app that reconnects its
websocket right away stays unreachable for up to a minute.

Refresh the session on visibilitychange and resume as well. The refresh is
shared with the timer: validate the session, create a new one when that
fails, and never run twice at once. A failed refresh no longer surfaces as
an unhandled rejection, the next one retries.
2026-11-13 06:07:00 +00:00
renovate[bot] 33f709cd21 Update Node.js to v24.22.3 (#55892)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-13 06:54:27 +01:00
renovate[bot] 1f77e4cd59 Update dependency typescript-eslint to v8.72.2 (#55891)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-13 06:54:10 +01:00
24 changed files with 886 additions and 719 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
24.22.2
24.22.3
-19
View File
@@ -1,19 +0,0 @@
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,7 +10,6 @@ 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;
@@ -167,7 +166,6 @@ export class DemoStateHistoryCharts extends LitElement {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
followGalleryTheme(this, hass);
hass.addEntities(ENTITIES);
}
@@ -1,9 +1,7 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { ChartSelectMode } from "../../../../src/components/chart/ha-chart-base";
import { customElement, property } from "lit/decorators";
import "../../../../src/components/chart/statistics-chart";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import type {
Statistics,
@@ -13,9 +11,7 @@ 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;
@@ -112,64 +108,6 @@ 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",
@@ -221,20 +159,17 @@ 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 html`${EXAMPLES.map(
return EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
@@ -256,66 +191,7 @@ 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`
@@ -326,20 +202,6 @@ 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.0.0",
"@versatiles/style": "9.1.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.1.3",
"marked": "18.2.0",
"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.1",
"@rspack/core": "2.3.2",
"@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.1",
"vite": "8.3.7",
"typescript-eslint": "8.72.2",
"vite": "8.3.8",
"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.2"
"node": "24.22.3"
}
}
+235 -248
View File
File diff suppressed because it is too large. Load diff
@@ -89,10 +89,19 @@ export class LegendFitController {
private _fit() {
const list = this._element;
const rowWidth = list?.getBoundingClientRect().width;
if (!list || !rowWidth) {
if (!list) {
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")
@@ -107,9 +116,7 @@ export class LegendFitController {
}
this._more = more;
}
const widths = children
.filter((child) => child !== more)
.map((child) => child.getBoundingClientRect().width);
const widths = children.filter((child) => child !== more).map(layoutWidth);
// 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) {
@@ -122,9 +129,9 @@ export class LegendFitController {
}
const fit = fitInRows(
this._widths,
more?.getBoundingClientRect().width ?? 0,
more ? layoutWidth(more) : 0,
rowWidth,
parseFloat(getComputedStyle(list).columnGap) || 0,
parseFloat(style.columnGap) || 0,
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
);
if (fit !== this.fit) {
+6 -5
View File
@@ -19,6 +19,7 @@ interface MinMaxFrame {
maxPoint: Point;
maxX: number;
maxY: number;
lastX: number;
gapPoint: Point | undefined;
}
@@ -68,6 +69,7 @@ function newFrame(
maxPoint: point,
maxX: x,
maxY: y,
lastX: x,
gapPoint,
};
}
@@ -174,6 +176,7 @@ 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) {
@@ -189,6 +192,7 @@ 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;
@@ -213,11 +217,8 @@ 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.
// 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) {
// That value may be neither min nor max, so check the frame's last x.
if (Number(getPointData(frame.gapPoint)[0]) >= frame.lastX) {
result.push(frame.gapPoint as T);
}
}
+4 -189
View File
@@ -7,10 +7,7 @@ import {
mdiRestart,
} from "@mdi/js";
import { differenceInMinutes } from "date-fns";
import type {
DataZoomComponentOption,
MarkAreaComponentOption,
} from "echarts/components";
import type { DataZoomComponentOption } from "echarts/components";
import type { EChartsType } from "echarts/core";
import type {
ECElementEvent,
@@ -93,29 +90,14 @@ 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"]>,
@@ -194,18 +176,6 @@ 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[];
@@ -231,14 +201,6 @@ 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;
@@ -492,9 +454,6 @@ 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;
@@ -540,8 +499,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
) {
this._disposeSonification();
}
} else if (changedProps.has("highlight")) {
this._updateMask();
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
@@ -931,9 +888,6 @@ 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;
@@ -1016,9 +970,6 @@ 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);
}
@@ -1070,10 +1021,7 @@ 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() &&
this.selectMode !== "range";
const active = !this._touchInput && this._hasZoomableXAxis();
if (!this.chart || active === this._dragToZoom) {
return;
}
@@ -1085,127 +1033,6 @@ 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;
@@ -1648,8 +1475,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _getSeries() {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
XAXisOption | undefined;
const maskArea = this._getMaskArea();
const series = ensureArray(this.data).map((s, index) => {
const series = ensureArray(this.data).map((s) => {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
@@ -1687,16 +1513,6 @@ 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"];
@@ -1946,7 +1762,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.selectMode === "range") &&
this._dragToZoom &&
this._isZoomPress(ev)
) {
this.chart
@@ -2521,7 +2337,6 @@ 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;
+2 -50
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, query, state } from "lit/decorators";
import { customElement, property, 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,13 +49,7 @@ import type {
HomeAssistantUI,
} from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type {
ChartSelectMode,
ChartTimeRange,
CustomLegendOption,
HaChartBase,
ZoomSliderMode,
} from "./ha-chart-base";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
@@ -134,16 +128,6 @@ 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"];
@@ -260,12 +244,8 @@ 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}
@@ -279,34 +259,6 @@ 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: () => undefined,
select: () => true,
fixDuplicateIds: async () => undefined,
updateTriggerId: () => false,
},
+15
View File
@@ -60,3 +60,18 @@ 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);
}
};
+51 -8
View File
@@ -16,7 +16,7 @@ import type { IngressAppInfo } from "../../data/hassio/ingress";
import {
createHassioSession,
fetchIngressAppInfo,
validateHassioSession,
refreshHassioSession,
} from "../../data/hassio/ingress";
import {
showAlertDialog,
@@ -60,6 +60,10 @@ class HaPanelApp extends LitElement {
private _sessionKeepAlive?: number;
private _session?: { slug: string; session: string };
private _sessionRefresh?: Promise<void>;
private _fetchDataTimeout?: number;
private _autoRetryUntil?: number;
@@ -92,17 +96,22 @@ 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;
@@ -342,17 +351,51 @@ class HaPanelApp extends LitElement {
if (this._sessionKeepAlive) {
clearInterval(this._sessionKeepAlive);
}
this._sessionKeepAlive = window.setInterval(async () => {
try {
await validateHassioSession(this.hass, session);
} catch (_err: any) {
session = await createHassioSession(this.hass, addonSlug);
}
}, 60000);
this._session = { slug: addonSlug, session };
this._sessionKeepAlive = window.setInterval(this._refreshSession, 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,8 +6,10 @@ 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";
@@ -17,6 +19,7 @@ import type { TriggerCondition } from "../../../../../data/automation";
import {
automationTriggerContext,
hasCustomTriggerId,
triggerOptionsResolved,
type AutomationTriggerContext,
type TriggerIdOption,
} from "../../trigger/automation-trigger-id";
@@ -53,16 +56,33 @@ 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) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
);
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 html`
${
missingIds.length
resolved && missingIds.length
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_triggers"
@@ -106,7 +126,13 @@ export class HaTriggerCondition extends LitElement {
appearance="checkbox"
>
<span slot="start" class="trigger-row-leading">
<span class="trigger-index-badge">${option.index + 1}</span>
${
this._triggers?.showIndices
? html`<span class="trigger-index-badge"
>${option.index + 1}</span
>`
: nothing
}
<ha-trigger-icon
.trigger=${
"trigger" in option.trigger ? option.trigger.trigger : ""
@@ -119,7 +145,9 @@ export class HaTriggerCondition extends LitElement {
)}
</span>
${
option.duplicate || hasCustomTriggerId(option)
option.duplicate ||
hasCustomTriggerId(option) ||
!this._triggers?.showIndices
? html`<span
slot="supporting-text"
class="trigger-id ${option.duplicate ? "duplicate" : ""}"
@@ -152,14 +180,22 @@ export class HaTriggerCondition extends LitElement {
.disabled=${this.disabled}
appearance="checkbox"
>
<span slot="start" class="missing-trigger-badge">
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
<span class="missing-trigger-id">${id}</span>
</span>
${
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="headline">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
)}
${
resolved
? this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
)
: id
}
</span>
</ha-list-item-option>
`
@@ -197,8 +233,7 @@ export class HaTriggerCondition extends LitElement {
? ids
: [...ids, id]
: ids.filter((_id) => _id !== id);
if (this._triggers) {
this._triggers.select(this.condition, selectedIds);
if (this._triggers?.select(this.condition, selectedIds)) {
return;
}
fireEvent(this, "value-changed", {
@@ -146,6 +146,15 @@ 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,11 +3,14 @@ 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";
@@ -16,12 +19,15 @@ 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";
@@ -31,6 +37,10 @@ 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>;
}
/**
@@ -43,6 +53,9 @@ 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.
@@ -54,6 +67,18 @@ 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
@@ -78,15 +103,39 @@ 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;
}
const options = this._getTriggerIdOptions(config.triggers);
if (options !== this._provider.value.options) {
this._provider.setValue({ ...this._provider.value, options });
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,
});
}
}
@@ -138,11 +187,55 @@ export class AutomationTriggerController implements ReactiveController {
return cleaned;
}
private _selectTriggerIds = (condition: TriggerCondition, ids: string[]) => {
const config = this._options.getConfig();
if (!config || !this._options.canEdit()) {
/** 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 => {
const config = this._options.getConfig();
if (config && "use_blueprint" in config) {
return false;
}
if (!config || !this._options.canEdit()) {
return true;
}
const triggers = assignGeneratedTriggerIds(
config.triggers,
this._getTriggerIdOptions(config.triggers),
@@ -157,6 +250,7 @@ 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,13 +38,28 @@ 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. */
select: (condition: TriggerCondition, ids: string[]) => void;
/**
* 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;
/** Requests confirmation before migrating duplicate IDs throughout the automation. */
fixDuplicateIds: () => Promise<void>;
/**
@@ -54,6 +69,13 @@ 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");
@@ -140,6 +162,27 @@ 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,6 +14,7 @@ import type { EntityRegistryEntry } from "../../../../data/entity/entity_registr
import type { HomeAssistant } from "../../../../types";
import {
automationTriggerContext,
triggerOptionsResolved,
type AutomationTriggerContext,
} from "./automation-trigger-id";
@@ -41,6 +42,7 @@ 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">
@@ -87,17 +89,21 @@ export class HaAutomationTriggerReferences extends LitElement {
</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>
`
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>`
),
];
}
-8
View File
@@ -15,8 +15,6 @@ import {
DataZoomComponent,
VisualMapComponent,
ToolboxComponent,
MarkAreaComponent,
BrushComponent,
} from "echarts/components";
// Features like Universal Transition and Label Layout
@@ -50,8 +48,6 @@ import type {
GridComponentOption,
DataZoomComponentOption,
VisualMapComponentOption,
MarkAreaComponentOption,
BrushComponentOption,
} from "echarts/components";
import type { ComposeOption } from "echarts/core";
@@ -66,8 +62,6 @@ export type ECOption = ComposeOption<
| GridComponentOption
| DataZoomComponentOption
| VisualMapComponentOption
| MarkAreaComponentOption
| BrushComponentOption
| SankeySeriesOption
| GraphSeriesOption
| SunburstSeriesOption
@@ -97,8 +91,6 @@ echarts.use([
UniversalTransition,
CanvasRenderer,
ToolboxComponent,
MarkAreaComponent,
BrushComponent,
]);
export { LinearGradient };
+2
View File
@@ -6442,6 +6442,8 @@
"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,6 +264,23 @@ 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
@@ -0,0 +1,78 @@
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");
});
});
@@ -0,0 +1,195 @@
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,6 +10,7 @@ import {
cleanupRemovedGeneratedTriggerReferences,
cleanupUnusedGeneratedTriggerIds,
GENERATED_TRIGGER_ID_PREFIX,
getBlueprintTriggerIdOptions,
getExplicitTriggerIds,
getTriggerIdOptions,
hasCustomTriggerId,
@@ -25,6 +26,40 @@ 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,