Compare commits

..
68 changed files with 1752 additions and 4062 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
24.22.3
24.22.2
+2 -2
View File
@@ -28,7 +28,7 @@ class HcLaunchScreen extends LitElement {
:host {
display: block;
height: 100vh;
background-color: var(--primary-background-color, #f2f4f9);
background-color: #f2f4f9;
font-size: var(--ha-font-size-2xl);
}
.container {
@@ -44,7 +44,7 @@ class HcLaunchScreen extends LitElement {
object-fit: cover;
}
.status {
color: var(--primary-text-color, #1d2126);
color: #1d2126;
}
`;
}
+6 -24
View File
@@ -8,7 +8,7 @@ import type {
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockEnergy = (hass: MockHomeAssistant) => {
const prefs: EnergyPreferences = {
hass.mockWS("energy/get_prefs", (): EnergyPreferences => ({
energy_sources: [
{
type: "grid",
@@ -48,23 +48,6 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
stat_energy_to: "sensor.battery_input",
stat_rate: "sensor.power_battery",
},
{
type: "battery",
name: "Powerwall",
stat_energy_from_grid: "sensor.powerwall_from_grid",
stat_energy_from_solar: "sensor.powerwall_from_solar",
stat_energy_to_home: "sensor.powerwall_to_home",
stat_energy_to_grid: "sensor.powerwall_to_grid",
stat_rate: "sensor.powerwall_power",
power_config: {
stat_rate_from_grid: "sensor.powerwall_power_from_grid",
stat_rate_from_solar: "sensor.powerwall_power_from_solar",
stat_rate_to_home: "sensor.powerwall_power_to_home",
stat_rate_to_grid: "sensor.powerwall_power_to_grid",
},
stat_soc: "sensor.powerwall_battery_level",
capacity: 13.5,
},
{
type: "gas",
stat_energy_from: "sensor.energy_gas",
@@ -122,17 +105,16 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
stat_consumption: "sensor.gas_heat_pump",
},
],
};
hass.mockWS("energy/get_prefs", (): EnergyPreferences => prefs);
}));
hass.mockWS("energy/info", (): EnergyInfo => ({
cost_sensors: {},
solar_forecast_domains: [],
}));
hass.mockWS("energy/validate", () => ({
energy_sources: prefs.energy_sources.map(() => []),
device_consumption: prefs.device_consumption.map(() => []),
device_consumption_water: prefs.device_consumption_water.map(() => []),
device_consumption_gas: prefs.device_consumption_gas.map(() => []),
energy_sources: Array.from({ length: 6 }, () => []),
device_consumption: Array.from({ length: 6 }, () => []),
device_consumption_water: Array.from({ length: 2 }, () => []),
device_consumption_gas: Array.from({ length: 2 }, () => []),
}));
hass.mockWS(
"energy/fossil_energy_consumption",
-75
View File
@@ -1,30 +1,3 @@
// Live power of the battery measured per direction, in W
const powerwallPower = {
from_grid: 50,
from_solar: 150,
to_home: 0,
to_grid: 0,
};
const powerEntity = (entityId: string, power: number) => ({
entity_id: entityId,
state: String(power),
attributes: {
state_class: "measurement",
unit_of_measurement: "W",
},
});
const powerwallEnergyEntity = (entityId: string, friendlyName: string) => ({
entity_id: entityId,
state: "2",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: friendlyName,
unit_of_measurement: "kWh",
},
});
export const energyEntities = () =>
Object.values({
"sensor.grid_fossil_fuel_percentage": {
@@ -61,31 +34,6 @@ export const energyEntities = () =>
unit_of_measurement: "kWh",
},
},
"sensor.powerwall_from_grid": powerwallEnergyEntity(
"sensor.powerwall_from_grid",
"Powerwall charged from grid"
),
"sensor.powerwall_from_solar": powerwallEnergyEntity(
"sensor.powerwall_from_solar",
"Powerwall charged from solar"
),
"sensor.powerwall_to_home": powerwallEnergyEntity(
"sensor.powerwall_to_home",
"Powerwall discharged to home"
),
"sensor.powerwall_to_grid": powerwallEnergyEntity(
"sensor.powerwall_to_grid",
"Powerwall discharged to grid"
),
"sensor.powerwall_battery_level": {
entity_id: "sensor.powerwall_battery_level",
state: "62",
attributes: {
device_class: "battery",
friendly_name: "Powerwall battery level",
unit_of_measurement: "%",
},
},
"sensor.energy_consumption_tarif_1": {
entity_id: "sensor.energy_consumption_tarif_1",
state: "88.6",
@@ -186,29 +134,6 @@ export const energyEntities = () =>
unit_of_measurement: "W",
},
},
"sensor.powerwall_power_from_grid": powerEntity(
"sensor.powerwall_power_from_grid",
powerwallPower.from_grid
),
"sensor.powerwall_power_from_solar": powerEntity(
"sensor.powerwall_power_from_solar",
powerwallPower.from_solar
),
"sensor.powerwall_power_to_home": powerEntity(
"sensor.powerwall_power_to_home",
powerwallPower.to_home
),
"sensor.powerwall_power_to_grid": powerEntity(
"sensor.powerwall_power_to_grid",
powerwallPower.to_grid
),
"sensor.powerwall_power": powerEntity(
"sensor.powerwall_power",
powerwallPower.to_home +
powerwallPower.to_grid -
powerwallPower.from_grid -
powerwallPower.from_solar
),
"sensor.energy_gas_cost": {
entity_id: "sensor.energy_gas_cost",
state: "2",
+6 -33
View File
@@ -351,26 +351,6 @@ const statisticsFunctions: Record<
const derivedCosts: Record<string, { source: string; price: number }> = {
"sensor.energy_water_cost": { source: "sensor.energy_water", price: 0.004 },
};
// Battery directions as a share of the meter the energy also passes, so they
// never exceed it
const derivedBatteryFlows: Record<string, { source: string; share: number }> = {
"sensor.powerwall_from_grid": {
source: "sensor.energy_consumption_tarif_1",
share: 0.4,
},
"sensor.powerwall_from_solar": {
source: "sensor.solar_production",
share: 0.3,
},
"sensor.powerwall_to_home": {
source: "sensor.energy_consumption_tarif_2",
share: 0.5,
},
"sensor.powerwall_to_grid": {
source: "sensor.energy_production_tarif_2",
share: 0.3,
},
};
export const mockRecorder = (mockHass: MockHomeAssistant) => {
mockHass.mockWS(
@@ -395,22 +375,15 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
return cache[id];
};
const scaleStats = (source: string, factor: number) => {
let sum = 0;
return getStats(source).map((statistic) => {
const change = statistic.change! * factor;
sum += change;
return { ...statistic, change, state: sum, sum };
});
};
statistic_ids.forEach((id: string) => {
if (id in derivedCosts) {
const { source, price } = derivedCosts[id];
statistics[id] = scaleStats(source, price);
} else if (id in derivedBatteryFlows) {
const { source, share } = derivedBatteryFlows[id];
statistics[id] = scaleStats(source, share);
let cost = 0;
statistics[id] = getStats(source).map((statistic) => {
const change = statistic.change! * price;
cost += change;
return { ...statistic, change, state: cost, sum: cost };
});
} else if (id in statisticsFunctions) {
statistics[id] = getStats(id);
} else {
+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);
}
`;
}
+16 -16
View File
@@ -40,7 +40,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.1.2",
"@babel/runtime": "8.1.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.4",
"@codemirror/commands": "6.11.1",
@@ -77,9 +77,9 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.24",
"@tsparticles/engine": "4.5.4",
"@tsparticles/preset-links": "4.5.4",
"@versatiles/style": "9.1.0",
"@tsparticles/engine": "4.5.3",
"@tsparticles/preset-links": "4.5.3",
"@versatiles/style": "9.0.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.204.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -108,8 +108,8 @@
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.18.0",
"marked": "18.2.0",
"maplibre-gl": "6.17.0",
"marked": "18.1.3",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -136,12 +136,12 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.1.2",
"@babel/core": "8.1.0",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-class-properties": "8.0.1",
"@babel/plugin-transform-private-methods": "8.0.1",
"@babel/plugin-transform-runtime": "8.1.2",
"@babel/plugin-transform-runtime": "8.0.6",
"@babel/preset-env": "8.0.7",
"@bundle-stats/plugin-webpack-filter": "4.23.1",
"@eslint/js": "10.0.1",
@@ -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",
@@ -188,7 +188,7 @@
"fancy-log": "2.0.0",
"fs-extra": "11.4.1",
"generate-license-file": "4.2.5",
"glob": "14.0.6",
"glob": "14.0.4",
"globals": "17.14.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
@@ -206,24 +206,24 @@
"map-stream": "0.0.7",
"minify-literals": "2.2.0",
"pinst": "3.0.0",
"prettier": "3.10.1",
"prettier": "3.9.10",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.7",
"sinon": "22.1.0",
"tar": "7.5.23",
"tar": "7.5.22",
"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",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "pnpm@12.20.0",
"packageManager": "pnpm@12.18.0",
"volta": {
"node": "24.22.3"
"node": "24.22.2"
}
}
+1 -1
View File
@@ -53,7 +53,7 @@
},
"devDependencies": {
"@custom-elements-manifest/analyzer": "0.11.0",
"@rslib/core": "1.0.7",
"@rslib/core": "1.0.6",
"lit": "3.3.3",
"typescript": "6.0.3"
}
+858 -854
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) {
+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");
+14 -17
View File
@@ -35,29 +35,26 @@ function computeTimelineStateColor(
const domain = computeDomain(stateObj.entity_id);
// Zone states would all share --state-person-active-color, so only an
// exact state variable may color them, like states of uncolored domains.
const isZoneState =
// Zone states for person/device_tracker don't have specific CSS color variables,
// so they all fall back to the same --state-person-active-color.
// Only use a custom CSS variable if explicitly defined (e.g. --state-person-kitchen-color),
// otherwise return undefined to get unique colors from the generic color handler.
if (
(domain === "person" || domain === "device_tracker") &&
!((FIXED_DOMAIN_STATES[domain] || []) as readonly string[]).includes(state);
const properties = isZoneState
? undefined
: stateColorProperties(stateObj, state);
if (!properties) {
const stateKey = slugify(state, "_");
const deviceClass = stateObj.attributes.device_class;
!((FIXED_DOMAIN_STATES[domain] || []) as readonly string[]).includes(state)
) {
return computeCssValue(
[
...(deviceClass
? [`--state-${domain}-${deviceClass}-${stateKey}-color`]
: []),
`--state-${domain}-${stateKey}-color`,
],
`--state-${domain}-${slugify(state, "_")}-color`,
computedStyles
);
}
const properties = stateColorProperties(stateObj, state);
if (!properties) {
return undefined;
}
const rgb = computeCssValue(properties, computedStyles);
if (!rgb) return undefined;
+1 -2
View File
@@ -87,12 +87,11 @@ export class HaFilterCategories extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeCategories();
this._categories = [];
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (changedProps.has("scope") && this.scope !== this._categoriesScope) {
if (changedProps.get("scope") !== undefined) {
this._categories = [];
}
}
+1 -5
View File
@@ -32,10 +32,6 @@ export class HaMarqueeText extends LitElement {
private _pauseTimeout?: number;
private get _animationFactor() {
return document.dir === "rtl" ? -1 : 1;
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
@@ -121,7 +117,7 @@ export class HaMarqueeText extends LitElement {
reachedEnd = true;
}
}
this._textSpan.style.transform = `translateX(${this._position * this._animationFactor}px)`;
this._textSpan.style.transform = `translateX(${this._position}px)`;
if (reachedEnd) {
this._pauseTimeout = window.setTimeout(() => {
this._pauseTimeout = undefined;
+4 -7
View File
@@ -465,13 +465,10 @@ export class HaServiceControl extends LitElement {
serviceData?.flatFields.some((field) => showOptionalToggle(field))
);
// Actions that can never have targets get null, like template targets
const targetEntities =
serviceData &&
("target" in serviceData ||
serviceData.flatFields.some((field) => field.key === "entity_id"))
? this._getTargetedEntities(serviceData.target, this._value)
: null;
const targetEntities = this._getTargetedEntities(
serviceData?.target,
this._value
);
const domain = this._value?.action
? computeDomain(this._value.action)
@@ -112,7 +112,7 @@ export class HaTracePathDetails extends LitElement {
initialValue: {
options: [],
showIndices: false,
select: () => true,
select: () => undefined,
fixDuplicateIds: async () => undefined,
updateTriggerId: () => false,
},
+1 -1
View File
@@ -123,7 +123,7 @@ export const setProtectedAlarmControlPanelMode = async (
(mode === "disarmed" && stateObj.attributes.code_format)
) {
const entry = await getExtendedEntityRegistryEntry(
hass.callWS,
hass,
stateObj.entity_id
).catch(() => undefined);
const defaultCode = entry?.options?.alarm_control_panel?.default_code;
+42 -324
View File
@@ -98,7 +98,7 @@ export const emptyHomeEnergyPreference =
});
export const emptyBatteryEnergyPreference =
(): BatteryTotalsSourceTypeEnergyPreference => ({
(): BatterySourceTypeEnergyPreference => ({
type: "battery",
stat_energy_from: "",
stat_energy_to: "",
@@ -190,125 +190,16 @@ export interface HomeSourceTypeEnergyPreference {
name?: string;
}
export interface BatteryTotalsSourceTypeEnergyPreference {
export interface BatterySourceTypeEnergyPreference {
type: "battery";
stat_energy_from: string; // out of the battery
stat_energy_to: string; // into the battery
stat_energy_from: string;
stat_energy_to: string;
stat_rate?: string; // always available if power_config is set
power_config?: PowerConfig;
stat_soc?: string;
capacity?: number; // usable capacity in kWh, used to weight the combined SOC
name?: string;
}
// Non-negative power sensors, any subset
export interface BatteryDirectionsPowerConfig {
stat_rate_from_grid?: string; // grid → battery
stat_rate_from_solar?: string; // solar → battery
stat_rate_to_home?: string; // battery → home
stat_rate_to_grid?: string; // battery → grid
}
// A missing direction is 0. At least one charge and one discharge direction is
// required, so getBatteryEnergyStatIds never returns an empty list.
export interface BatteryDirectionsSourceTypeEnergyPreference {
type: "battery";
stat_energy_from?: undefined;
stat_energy_to?: undefined;
stat_energy_from_grid?: string; // grid → battery
stat_energy_from_solar?: string; // solar → battery
stat_energy_to_home?: string; // battery → home
stat_energy_to_grid?: string; // battery → grid
// Net power generated from power_config, + discharge and − charge
stat_rate?: string;
power_config?: BatteryDirectionsPowerConfig;
stat_soc?: string;
capacity?: number;
name?: string;
}
export type BatterySourceTypeEnergyPreference =
| BatteryTotalsSourceTypeEnergyPreference
| BatteryDirectionsSourceTypeEnergyPreference;
export interface BatteryFlowStatIds {
grid_to_battery?: string;
solar_to_battery?: string;
battery_to_home?: string;
battery_to_grid?: string;
}
export const BATTERY_FLOW_KEYS = [
"grid_to_battery",
"solar_to_battery",
"battery_to_home",
"battery_to_grid",
] as const satisfies readonly (keyof BatteryFlowStatIds)[];
export const isBatteryDirections = (
source: BatterySourceTypeEnergyPreference
): source is BatteryDirectionsSourceTypeEnergyPreference => {
const directions = source as BatteryDirectionsSourceTypeEnergyPreference;
return !!(
directions.stat_energy_from_grid ||
directions.stat_energy_from_solar ||
directions.stat_energy_to_home ||
directions.stat_energy_to_grid
);
};
/** The direction statistics, or undefined for a battery configured with totals. */
export const getBatteryFlowStatIds = (
source: BatterySourceTypeEnergyPreference
): BatteryFlowStatIds | undefined =>
isBatteryDirections(source)
? {
grid_to_battery: source.stat_energy_from_grid || undefined,
solar_to_battery: source.stat_energy_from_solar || undefined,
battery_to_home: source.stat_energy_to_home || undefined,
battery_to_grid: source.stat_energy_to_grid || undefined,
}
: undefined;
/** The direction power sensors, or undefined for a battery configured with totals. */
export const getBatteryFlowPowerIds = (
source: BatterySourceTypeEnergyPreference
): BatteryFlowStatIds | undefined =>
isBatteryDirections(source)
? {
grid_to_battery: source.power_config?.stat_rate_from_grid || undefined,
solar_to_battery:
source.power_config?.stat_rate_from_solar || undefined,
battery_to_home: source.power_config?.stat_rate_to_home || undefined,
battery_to_grid: source.power_config?.stat_rate_to_grid || undefined,
}
: undefined;
/** Statistics whose sum is the energy going into and coming out of the battery. */
export const getBatteryEnergyStatIds = (
source: BatterySourceTypeEnergyPreference
): { charge: string[]; discharge: string[] } => {
if (!isBatteryDirections(source)) {
return {
charge: [source.stat_energy_to],
discharge: [source.stat_energy_from],
};
}
const ids = (...stats: (string | undefined)[]) =>
stats.filter((id): id is string => !!id);
return {
charge: ids(source.stat_energy_from_grid, source.stat_energy_from_solar),
discharge: ids(source.stat_energy_to_home, source.stat_energy_to_grid),
};
};
/** Identifies a battery in page keys and lookups. */
export const getBatteryPrimaryStat = (
source: BatterySourceTypeEnergyPreference
): string => {
const { charge, discharge } = getBatteryEnergyStatIds(source);
return discharge[0] ?? charge[0] ?? "";
};
export interface GasSourceTypeEnergyPreference {
type: "gas";
@@ -501,28 +392,6 @@ export const computeEnergyLabel = (
);
};
/**
* Name that cards show as "<name> charged" and "<name> discharged". Unnamed
* batteries configured with totals are labelled by each statistic instead.
*/
export const getBatteryName = (
states: HomeAssistant["states"],
formatEntityName: HomeAssistant["formatEntityName"],
source: BatterySourceTypeEnergyPreference,
statsMetadata?: Record<string, StatisticsMetaData>
): string | undefined => {
if (source.name || !isBatteryDirections(source)) {
return source.name;
}
const primary = getBatteryPrimaryStat(source);
return computeEnergyLabel(
states,
formatEntityName,
primary,
statsMetadata?.[primary]
);
};
export const computeEnergyStatisticArea = (
registries: HomeAssistantRegistries,
statisticId: string
@@ -683,8 +552,8 @@ export const getReferencedStatisticIds = (
}
if (source.type === "battery") {
const { charge, discharge } = getBatteryEnergyStatIds(source);
statIDs.push(...discharge, ...charge);
statIDs.push(source.stat_energy_from);
statIDs.push(source.stat_energy_to);
continue;
}
@@ -1768,8 +1637,7 @@ const getEnergyGasUnit = (
return [source.stat_energy_to];
}
if (source.type === "battery") {
const { charge, discharge } = getBatteryEnergyStatIds(source);
return [...discharge, ...charge];
return [source.stat_energy_from, source.stat_energy_to];
}
if (source.type === "grid") {
return [source.stat_energy_from, source.stat_energy_to].filter(
@@ -1842,14 +1710,6 @@ export interface EnergySumData {
from_battery?: Record<number, number>;
solar?: Record<number, number>;
home?: Record<number, number>;
// Set together, only when a battery has direction statistics
grid_to_battery?: Record<number, number>;
solar_to_battery?: Record<number, number>;
battery_to_home?: Record<number, number>;
battery_to_grid?: Record<number, number>;
// Charge and discharge of the batteries configured with totals
to_battery_totals?: Record<number, number>;
from_battery_totals?: Record<number, number>;
total: {
to_grid?: number;
from_grid?: number;
@@ -1857,12 +1717,6 @@ export interface EnergySumData {
from_battery?: number;
solar?: number;
home?: number;
grid_to_battery?: number;
solar_to_battery?: number;
battery_to_home?: number;
battery_to_grid?: number;
to_battery_totals?: number;
from_battery_totals?: number;
};
timestamps: number[];
}
@@ -1904,10 +1758,14 @@ const getSummedDataPartial = (
data: EnergyData,
compare?: boolean
): EnergySumData => {
const statIds: Partial<
Record<Exclude<keyof EnergySumData, "total" | "timestamps">, string[]>
> = {};
const batteries: BatterySourceTypeEnergyPreference[] = [];
const statIds: {
to_grid?: string[];
from_grid?: string[];
solar?: string[];
to_battery?: string[];
from_battery?: string[];
home?: string[];
} = {};
for (const source of data.prefs.energy_sources) {
if (source.type === "solar") {
@@ -1925,14 +1783,12 @@ const getSummedDataPartial = (
}
if (source.type === "battery") {
batteries.push(source);
const { charge, discharge } = getBatteryEnergyStatIds(source);
if (statIds.to_battery) {
statIds.to_battery.push(...charge);
statIds.from_battery!.push(...discharge);
statIds.to_battery.push(source.stat_energy_to);
statIds.from_battery!.push(source.stat_energy_from);
} else {
statIds.to_battery = [...charge];
statIds.from_battery = [...discharge];
statIds.to_battery = [source.stat_energy_to];
statIds.from_battery = [source.stat_energy_from];
}
continue;
}
@@ -1958,23 +1814,6 @@ const getSummedDataPartial = (
}
}
if (batteries.some(isBatteryDirections)) {
for (const key of BATTERY_FLOW_KEYS) {
statIds[key] = batteries.flatMap(
(battery) => getBatteryFlowStatIds(battery)?.[key] ?? []
);
}
const totalsBatteries = batteries.filter(
(battery) => !isBatteryDirections(battery)
);
statIds.to_battery_totals = totalsBatteries.flatMap(
(battery) => getBatteryEnergyStatIds(battery).charge
);
statIds.from_battery_totals = totalsBatteries.flatMap(
(battery) => getBatteryEnergyStatIds(battery).discharge
);
}
const summedData: EnergySumData = { total: {}, timestamps: [] };
const timestamps = new Set<number>();
Object.entries(statIds).forEach(([key, subStatIds]) => {
@@ -2045,12 +1884,11 @@ const computeConsumptionDataPartial = (
},
};
const measured = BATTERY_FLOW_KEYS.some((key) => data[key]);
const fromGrid = data.from_grid;
const toGrid = data.to_grid;
const solarData = data.solar;
const toBattery = measured ? data.to_battery_totals : data.to_battery;
const fromBattery = measured ? data.from_battery_totals : data.from_battery;
const toBattery = data.to_battery;
const fromBattery = data.from_battery;
const total = outData.total;
data.timestamps.forEach((t) => {
@@ -2065,25 +1903,13 @@ const computeConsumptionDataPartial = (
used_total,
solar_to_battery,
solar_to_grid,
} = computeConsumptionSingle(
{
from_grid: fromGrid && (fromGrid[t] ?? 0),
to_grid: toGrid && (toGrid[t] ?? 0),
solar: solarData && (solarData[t] ?? 0),
to_battery: toBattery && (toBattery[t] ?? 0),
from_battery: fromBattery && (fromBattery[t] ?? 0),
},
measured
? {
measured: {
grid_to_battery: data.grid_to_battery?.[t] ?? 0,
solar_to_battery: data.solar_to_battery?.[t] ?? 0,
battery_to_home: data.battery_to_home?.[t] ?? 0,
battery_to_grid: data.battery_to_grid?.[t] ?? 0,
},
}
: undefined
);
} = computeConsumptionSingle({
from_grid: fromGrid && (fromGrid[t] ?? 0),
to_grid: toGrid && (toGrid[t] ?? 0),
solar: solarData && (solarData[t] ?? 0),
to_battery: toBattery && (toBattery[t] ?? 0),
from_battery: fromBattery && (fromBattery[t] ?? 0),
});
const usedTotal = hasHome ? Math.max(homeUsed, 0) : used_total;
outData.used_total[t] = usedTotal;
@@ -2107,15 +1933,13 @@ const computeConsumptionDataPartial = (
return outData;
};
interface ConsumptionInput {
export const computeConsumptionSingle = (data: {
from_grid: number | undefined;
to_grid: number | undefined;
solar: number | undefined;
to_battery: number | undefined;
from_battery: number | undefined;
}
interface ConsumptionResult {
}): {
grid_to_battery: number;
battery_to_grid: number;
solar_to_battery: number;
@@ -2124,12 +1948,7 @@ interface ConsumptionResult {
used_grid: number;
used_battery: number;
used_total: number;
}
const inferConsumption = (
data: ConsumptionInput,
usedTotal?: number
): ConsumptionResult => {
} => {
let to_grid = Math.max(data.to_grid || 0, 0);
let to_battery = Math.max(data.to_battery || 0, 0);
let solar = Math.max(data.solar || 0, 0);
@@ -2137,12 +1956,11 @@ const inferConsumption = (
let from_battery = Math.max(data.from_battery || 0, 0);
const used_total =
usedTotal ??
(from_grid || 0) +
(solar || 0) +
(from_battery || 0) -
(to_grid || 0) -
(to_battery || 0);
(solar || 0) +
(from_battery || 0) -
(to_grid || 0) -
(to_battery || 0);
let grid_to_battery = 0;
@@ -2210,70 +2028,6 @@ const inferConsumption = (
};
};
export type MeasuredBatteryFlows = Record<
(typeof BATTERY_FLOW_KEYS)[number],
number | undefined
>;
/**
* Splits the energy or power of one period into flows. Batteries with
* direction statistics pass them in `measured`, and `to_battery` and
* `from_battery` then only hold the batteries configured with totals.
* Measured flows are kept as they are; only the rest is estimated. Leave a
* meter undefined when it isn't configured, rather than 0.
*/
export const computeConsumptionSingle = (
data: ConsumptionInput,
options?: { measured?: MeasuredBatteryFlows; used_total?: number }
): ConsumptionResult => {
const measured = options?.measured;
if (!measured) {
return inferConsumption(data, options?.used_total);
}
const clamp = (value: number | undefined) => Math.max(value || 0, 0);
const gridToBattery = clamp(measured.grid_to_battery);
const solarToBattery = clamp(measured.solar_to_battery);
const batteryToHome = clamp(measured.battery_to_home);
const batteryToGrid = clamp(measured.battery_to_grid);
const fromGrid = clamp(data.from_grid);
const toGrid = clamp(data.to_grid);
const solar = clamp(data.solar);
// A flow without a configured meter never passed one, so it stays out of
// the home load
const metered = (meter: number | undefined, flow: number) =>
meter === undefined ? 0 : flow;
const toBattery =
clamp(data.to_battery) +
metered(data.from_grid, gridToBattery) +
metered(data.solar, solarToBattery);
const fromBattery =
clamp(data.from_battery) +
batteryToHome +
metered(data.to_grid, batteryToGrid);
const usedTotal =
options.used_total ?? fromGrid + solar + fromBattery - toGrid - toBattery;
// Only what the measured flows leave over is estimated
const inferred = inferConsumption(
{
from_grid: Math.max(fromGrid - gridToBattery, 0),
to_grid: Math.max(toGrid - batteryToGrid, 0),
solar: Math.max(solar - solarToBattery, 0),
to_battery: data.to_battery,
from_battery: data.from_battery,
},
Math.max(usedTotal - batteryToHome, 0)
);
return {
...inferred,
grid_to_battery: inferred.grid_to_battery + gridToBattery,
solar_to_battery: inferred.solar_to_battery + solarToBattery,
battery_to_grid: inferred.battery_to_grid + batteryToGrid,
used_battery: inferred.used_battery + batteryToHome,
used_total: usedTotal,
};
};
export const formatConsumptionShort = (
locale: HomeAssistant["locale"],
consumption: number | null,
@@ -2763,54 +2517,18 @@ export const downloadEnergyData = (
grid_productions_cost
);
const batteries = energy_sources.filter(
(s): s is BatterySourceTypeEnergyPreference => s.type === "battery"
);
const battery_ins: string[] = [];
const battery_outs: string[] = [];
const battery_flows: Record<keyof BatteryFlowStatIds, string[]> = {
grid_to_battery: [],
solar_to_battery: [],
battery_to_home: [],
battery_to_grid: [],
};
batteries.forEach((source) => {
const flows = getBatteryFlowStatIds(source);
if (!flows) {
const { charge, discharge } = getBatteryEnergyStatIds(source);
battery_ins.push(...charge);
battery_outs.push(...discharge);
return;
}
BATTERY_FLOW_KEYS.forEach((key) => {
if (flows[key]) {
battery_flows[key].push(flows[key]);
}
energy_sources
.filter((s) => s.type === "battery")
.forEach((source) => {
source = source as BatterySourceTypeEnergyPreference;
battery_ins.push(source.stat_energy_to);
battery_outs.push(source.stat_energy_from);
});
});
printCategory("battery_in", battery_ins, electricUnit);
printCategory("battery_out", battery_outs, electricUnit);
printCategory(
"battery_in_from_grid",
battery_flows.grid_to_battery,
electricUnit
);
printCategory(
"battery_in_from_solar",
battery_flows.solar_to_battery,
electricUnit
);
printCategory(
"battery_out_to_home",
battery_flows.battery_to_home,
electricUnit
);
printCategory(
"battery_out_to_grid",
battery_flows.battery_to_grid,
electricUnit
);
const solar_productions: string[] = [];
energy_sources
@@ -2927,7 +2645,7 @@ export const downloadEnergyData = (
const hasSolar = !!solar_productions.length;
const hasHome = !!home_consumptions.length;
const hasBattery = batteries.length > 0;
const hasBattery = !!battery_ins.length;
const hasGridReturn = !!grid_productions.length;
const hasGridSource = !!grid_consumptions.length;
+2 -2
View File
@@ -266,10 +266,10 @@ export const computeEntityRegistryName = (
};
export const getExtendedEntityRegistryEntry = (
callWS: HomeAssistant["callWS"],
hass: Pick<HomeAssistant, "callWS">,
entityId: string
): Promise<ExtEntityRegistryEntry> =>
callWS({
hass.callWS({
type: "config/entity_registry/get",
entity_id: entityId,
});
-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);
}
};
+5 -5
View File
@@ -6,7 +6,7 @@ import type { LocalizeFunc } from "../common/translations/localize";
import { showEnterCodeDialog } from "../dialogs/enter-code/show-enter-code-dialog";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE } from "./entity/entity";
import { getExtendedEntityRegistryEntry } from "./entity/entity_registry";
import type { ExtEntityRegistryEntry } from "./entity/entity_registry";
export enum LockEntityFeature {
OPEN = 1,
@@ -88,10 +88,10 @@ export const callProtectedLockService = async (
service: ProtectedLockService
) => {
let code: string | undefined;
const lockRegistryEntry = await getExtendedEntityRegistryEntry(
callWS,
stateObj.entity_id
).catch(() => undefined);
const lockRegistryEntry = await callWS<ExtEntityRegistryEntry>({
type: "config/entity_registry/get",
entity_id: stateObj.entity_id,
}).catch(() => undefined);
const defaultCode = lockRegistryEntry?.options?.lock?.default_code;
if (stateObj!.attributes.code_format && !defaultCode) {
+19 -28
View File
@@ -20,7 +20,7 @@ import {
registriesContext,
uiContext,
} from "./context";
import type { HomeAssistant, HomeAssistantRegistries } from "../types";
import type { HomeAssistant } from "../types";
/**
* Reactive controller that keeps `NavigationPathInfo` in sync with a
@@ -45,8 +45,19 @@ export class NavigationPathInfoController extends ContextController {
})
private _panels?: HomeAssistant["panels"];
@consumeContext({ context: registriesContext, subscribe: true })
private _registries?: HomeAssistantRegistries | null;
@consumeContext({
context: registriesContext,
subscribe: true,
transform: ({ areas }) => areas,
})
private _areas?: HomeAssistant["areas"];
@consumeContext({
context: registriesContext,
subscribe: true,
transform: ({ devices }) => devices,
})
private _devices?: HomeAssistant["devices"];
@consumeContext({
context: configContext,
@@ -68,8 +79,6 @@ export class NavigationPathInfoController extends ContextController {
private _path?: string;
private _resolvedInputs?: unknown[];
get info(): NavigationPathInfo {
return this._info;
}
@@ -88,31 +97,13 @@ export class NavigationPathInfoController extends ContextController {
this._unsubscribe();
}
// The resolved info depends on every consumed value, so re-resolve.
protected contextUpdated(): void {
const inputs = this._inputs();
if (
this._resolvedInputs &&
inputs.every((value, i) => value === this._resolvedInputs![i])
) {
return;
}
this._resolve();
}
private _inputs(): unknown[] {
return [
this._localize,
this._panels,
this._registries?.areas,
this._registries?.devices,
this._config,
this._connection,
];
}
private _resolve(): void {
this._unsubscribe();
this._resolvedInputs = this._inputs();
if (!this._path) {
if (this._info !== DEFAULT_NAVIGATION_PATH_INFO) {
@@ -125,8 +116,8 @@ export class NavigationPathInfoController extends ContextController {
if (
!this._localize ||
!this._panels ||
!this._registries?.areas ||
!this._registries.devices ||
!this._areas ||
!this._devices ||
!this._config ||
!this._connection
) {
@@ -137,8 +128,8 @@ export class NavigationPathInfoController extends ContextController {
{
localize: this._localize,
panels: this._panels,
areas: this._registries.areas,
devices: this._registries.devices,
areas: this._areas,
devices: this._devices,
config: this._config,
connection: this._connection,
},
@@ -19,7 +19,7 @@ export const fetchEntityConfigFlow = async (
if (!entities || !(entityId in entities)) {
return undefined;
}
const regEntry = await getExtendedEntityRegistryEntry(api.callWS, entityId);
const regEntry = await getExtendedEntityRegistryEntry(api, entityId);
if (!regEntry.config_entry_id) {
return undefined;
}
@@ -22,7 +22,7 @@ export class HaMoreInfoViewVacuumCleanAreasHeaderAction extends LitElement {
if (!this.params.entityId || !this.hass.user?.is_admin) return;
try {
const entry = await getExtendedEntityRegistryEntry(
this.hass.callWS,
this.hass,
this.params.entityId
);
const areaMapping = entry?.options?.vacuum?.area_mapping;
@@ -56,10 +56,7 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
try {
const entry: ExtEntityRegistryEntry =
await getExtendedEntityRegistryEntry(
this.hass.callWS,
this.params.entityId
);
await getExtendedEntityRegistryEntry(this.hass, this.params.entityId);
const areaMapping = entry?.options?.vacuum?.area_mapping || {};
const mappedAreaIds = new Set(Object.keys(areaMapping));
@@ -50,7 +50,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
if (!this.params.entityId) return;
this._entry = await getExtendedEntityRegistryEntry(
this.hass.callWS,
this.hass,
this.params.entityId
);
+1 -1
View File
@@ -217,7 +217,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
try {
this._entry = await getExtendedEntityRegistryEntry(
this.hass.callWS,
this.hass,
this._entityId
);
} catch (_e) {
+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>
`
),
];
}
@@ -16,12 +16,7 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import {
getBatteryEnergyStatIds,
getBatteryName,
getBatteryPrimaryStat,
saveEnergyPreferences,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
@@ -119,9 +114,8 @@ export class EnergyBatterySettings extends LitElement {
? html`
<div class="items">
${batterySources.map((source) => {
const chargeStat =
getBatteryEnergyStatIds(source).charge[0];
const toEntityState = this.hass.states[chargeStat];
const toEntityState =
this.hass.states[source.stat_energy_to];
return html`
<ha-card outlined>
<ha-automation-row
@@ -151,8 +145,10 @@ export class EnergyBatterySettings extends LitElement {
>${getStatisticLabel(
this.hass.states,
this.hass.formatEntityName,
chargeStat,
this.statsMetadata?.[chargeStat]
source.stat_energy_to,
this.statsMetadata?.[
source.stat_energy_to
]
)}</span
>`
}
@@ -192,19 +188,13 @@ export class EnergyBatterySettings extends LitElement {
}
private _sourceName(source: BatterySourceTypeEnergyPreference) {
const primary = getBatteryPrimaryStat(source);
return (
getBatteryName(
this.hass.states,
this.hass.formatEntityName,
source,
this.statsMetadata
) ||
source.name ||
getStatisticLabel(
this.hass.states,
this.hass.formatEntityName,
primary,
this.statsMetadata?.[primary]
source.stat_energy_from,
this.statsMetadata?.[source.stat_energy_from]
)
);
}
@@ -225,7 +215,7 @@ export class EnergyBatterySettings extends LitElement {
private _editorHref(source?: BatterySourceTypeEnergyPreference) {
return this.listPath
? `${this.listPath}/battery/${source ? encodeURIComponent(getBatteryPrimaryStat(source)) : "new"}`
? `${this.listPath}/battery/${source ? encodeURIComponent(source.stat_energy_from) : "new"}`
: undefined;
}
@@ -251,9 +241,9 @@ export class EnergyBatterySettings extends LitElement {
return index;
}
// Saving replaces the preferences, after which only the statistic matches
const key = getBatteryPrimaryStat(source);
return sources.findIndex(
(src) => isBatterySource(src) && getBatteryPrimaryStat(src) === key
(src) =>
isBatterySource(src) && src.stat_energy_from === source.stat_energy_from
);
}
@@ -265,7 +255,7 @@ export class EnergyBatterySettings extends LitElement {
}
const source = this.preferences.energy_sources.find(
(src): src is BatterySourceTypeEnergyPreference =>
isBatterySource(src) && getBatteryPrimaryStat(src) === key
isBatterySource(src) && src.stat_energy_from === key
);
return (
source &&
@@ -1,7 +1,4 @@
import type {
BatteryDirectionsPowerConfig,
PowerConfig,
} from "../../../../data/energy";
import type { PowerConfig } from "../../../../data/energy";
import { deepEqual } from "../../../../common/util/deep-equal";
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
@@ -71,10 +68,7 @@ export function isPowerConfigValid(
* Builds an exclude list for power statistics from existing sources.
*/
export function buildPowerExcludeList(
sources: {
stat_rate?: string;
power_config?: PowerConfig | BatteryDirectionsPowerConfig;
}[],
sources: { stat_rate?: string; power_config?: PowerConfig }[],
currentPowerConfig: PowerConfig,
currentStatRate?: string
): string[] {
@@ -83,9 +77,18 @@ export function buildPowerExcludeList(
sources.forEach((entry) => {
if (entry.stat_rate) powerIds.push(entry.stat_rate);
if (entry.power_config) {
powerIds.push(
...Object.values(entry.power_config).filter((id): id is string => !!id)
);
if (entry.power_config.stat_rate) {
powerIds.push(entry.power_config.stat_rate);
}
if (entry.power_config.stat_rate_inverted) {
powerIds.push(entry.power_config.stat_rate_inverted);
}
if (entry.power_config.stat_rate_from) {
powerIds.push(entry.power_config.stat_rate_from);
}
if (entry.power_config.stat_rate_to) {
powerIds.push(entry.power_config.stat_rate_to);
}
}
});
@@ -14,15 +14,13 @@ import "../../../../components/ha-alert";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import type {
BatteryTotalsSourceTypeEnergyPreference,
BatterySourceTypeEnergyPreference,
PowerConfig,
} from "../../../../data/energy";
import {
computeEnergyLabel,
emptyBatteryEnergyPreference,
energyStatisticHelpUrl,
getBatteryEnergyStatIds,
isBatteryDirections,
} from "../../../../data/energy";
import {
getStatisticLabel,
@@ -63,7 +61,7 @@ import type {
import { EnergyEditorMixin, energyEditorStyles } from "./energy-editor";
interface BatteryFormState {
source: BatteryTotalsSourceTypeEnergyPreference;
source: BatterySourceTypeEnergyPreference;
powerType: PowerType;
powerConfig: PowerConfig;
}
@@ -123,7 +121,7 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
})
private _pending?: PowerIntegral[];
@state() private _source?: BatteryTotalsSourceTypeEnergyPreference;
@state() private _source?: BatterySourceTypeEnergyPreference;
@state() private _powerType: PowerType = "none";
@@ -150,23 +148,23 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
const params = this.params;
this._error = undefined;
this._saving = false;
const savedSource = this._savedSource;
this._source = savedSource
? { ...savedSource }
this._source = params.source
? { ...params.source }
: emptyBatteryEnergyPreference();
this._powerType = getPowerTypeFromConfig(
savedSource?.power_config,
savedSource?.stat_rate
params.source?.power_config,
params.source?.stat_rate
);
this._powerConfig = getInitialPowerConfig(
savedSource?.power_config,
savedSource?.stat_rate
params.source?.power_config,
params.source?.stat_rate
);
const allSources = params.battery_sources.flatMap((entry) => {
const { charge, discharge } = getBatteryEnergyStatIds(entry);
return [...discharge, ...charge];
const allSources: string[] = [];
params.battery_sources.forEach((entry) => {
allSources.push(entry.stat_energy_from);
allSources.push(entry.stat_energy_to);
});
this._excludeList = allSources.filter(
(id) =>
@@ -177,7 +175,7 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
this._excludeListPower = buildPowerExcludeList(
params.battery_sources,
this._powerConfig,
savedSource?.stat_rate
params.source?.stat_rate
);
this._initDirtyTracking(
@@ -191,13 +189,6 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
this._loadUnits();
}
// Only batteries configured with totals can be edited here
private get _savedSource():
BatteryTotalsSourceTypeEnergyPreference | undefined {
const source = this.params.source;
return source && !isBatteryDirections(source) ? source : undefined;
}
private async _loadUnits() {
this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this._api.callWS, "energy")
@@ -295,7 +286,7 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
.powerConfig=${this._powerConfig}
.excludeList=${this._excludeListPower}
.helperEntityId=${getPowerHelperEntityId(
this._savedSource,
this.params.source,
this._powerConfig
)}
.localizeBaseKey=${"ui.panel.config.energy.battery.dialog"}
@@ -457,7 +448,7 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
}
}
const source: BatteryTotalsSourceTypeEnergyPreference = {
const source: BatterySourceTypeEnergyPreference = {
type: "battery",
stat_energy_from: draft.stat_energy_from,
stat_energy_to: draft.stat_energy_to,
@@ -496,7 +487,7 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
}
private _energyFromPower(
draft: BatteryTotalsSourceTypeEnergyPreference,
draft: BatterySourceTypeEnergyPreference,
pending: PowerIntegral[]
): Promise<string[] | undefined> {
return confirmIntegrals(this, {
@@ -77,10 +77,7 @@ export class DialogVacuumSegmentMapping
if (!this._params) return;
const entityId = this._params.entityId;
this._entry = await getExtendedEntityRegistryEntry(
this.hass.callWS,
entityId
);
this._entry = await getExtendedEntityRegistryEntry(this.hass, entityId);
if (this._entry?.options?.vacuum) {
this._areaMapping = this._entry.options.vacuum.area_mapping || {};
@@ -601,7 +601,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
try {
// The entity ID usually matches the helper ID, unless it was taken
const entry = await getExtendedEntityRegistryEntry(
this._api.callWS,
this._api,
`${domain}.${uniqueId}`
).catch(() => undefined);
if (entry && isCreated(entry)) {
@@ -225,10 +225,12 @@ class MqttSubscribeCard extends LitElement {
static styles = css`
form {
padding: var(--ha-space-4) 0 var(--ha-space-8);
padding: var(--ha-space-4);
padding-bottom: var(--ha-space-8);
}
.content_subscribe_panel {
max-width: var(--ha-content-width-narrow);
margin-top: var(--ha-space-6);
max-width: 600px;
margin: 0 auto;
direction: ltr;
}
+1 -1
View File
@@ -99,7 +99,7 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
}
let entity: ExtEntityRegistryEntry | undefined;
try {
entity = await getExtendedEntityRegistryEntry(this.hass.callWS, id);
entity = await getExtendedEntityRegistryEntry(this.hass, id);
} catch {
// not in the registry
}
@@ -419,7 +419,7 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
);
if (this.entry) {
const entry = await getExtendedEntityRegistryEntry(
this.hass.callWS,
this.hass,
this.entityId
);
fireEvent(this, "entity-entry-updated", entry);
@@ -437,7 +437,7 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
exposeEntities(this.hass, assistants, [this.entityId], ev.target.checked);
if (this.entry) {
const entry = await getExtendedEntityRegistryEntry(
this.hass.callWS,
this.hass,
this.entityId
);
fireEvent(this, "entity-entry-updated", entry);
@@ -192,10 +192,7 @@ export abstract class HuiNumericFavoriteCardFeatureBase<
private async _loadEntityEntry(entityId: string): Promise<void> {
try {
const entry = await getExtendedEntityRegistryEntry(
this._api.callWS,
entityId
);
const entry = await getExtendedEntityRegistryEntry(this._api, entityId);
if (this.context?.entity_id === entityId) {
this._entry = entry;
@@ -18,15 +18,11 @@ import type {
EnergyConsumptionData,
EnergyData,
EnergySourceByType,
EnergySumData,
} from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
energySourcesByType,
getBatteryEnergyStatIds,
getBatteryFlowStatIds,
getBatteryName,
getSummedData,
validateEnergyCollectionKey,
} from "../../../../data/energy";
@@ -335,7 +331,7 @@ class HuiEnergySankeyCard
typeNodes,
typeLinks,
consumption,
summedData,
summedData.timestamps,
computedStyle
);
nodes.push(...separate.nodes);
@@ -444,47 +440,33 @@ class HuiEnergySankeyCard
typeNodes: Node[],
typeLinks: Link[],
consumption: EnergyConsumptionData,
summedData: EnergySumData,
timestamps: number[],
computedStyle: CSSStyleDeclaration
): { nodes: Node[]; links: Link[] } {
interface SourceNode {
id: string;
values?: Record<number, number>;
// Flows of a battery with direction statistics, by the other node
measured?: Record<string, Record<number, number>>;
}
const nodes: Node[] = [];
const sumSeries = (statIds: (string | undefined)[]) => {
const values: Record<number, number> = {};
let total = 0;
statIds.forEach((statId) => {
if (!statId) {
return;
}
this._data!.stats[statId]?.forEach(({ start, change }) => {
if (change === null || change === undefined) {
return;
}
values[start] = (values[start] ?? 0) + change;
total += change;
});
});
return { values, total };
};
const addNode = (
type: string,
statIds: string[],
statId: string,
colorVariable: string,
idx: number,
index: number,
name: string | undefined,
measured?: SourceNode["measured"]
name: string | undefined
): SourceNode => {
const statId = statIds[0];
const id = `${type}:${statId}`;
const { values, total } = sumSeries(statIds);
const values: Record<number, number> = {};
let total = 0;
this._data!.stats[statId]?.forEach(({ start, change }) => {
if (change === null || change === undefined) {
return;
}
values[start] = (values[start] ?? 0) + change;
total += change;
});
nodes.push({
id,
label:
@@ -505,11 +487,10 @@ class HuiEnergySankeyCard
idx
),
index,
entityId:
statIds.length === 1 && statId in this._states ? statId : undefined,
entityId: statId in this._states ? statId : undefined,
group: type,
});
return { id, values, measured };
return { id, values };
};
const addGroup = <T>(
@@ -524,62 +505,41 @@ class HuiEnergySankeyCard
return sources.map(toNode);
};
const batteryName = (
source: NonNullable<EnergySourceByType["battery"]>[number],
key: "named_battery_discharged" | "named_battery_charged"
) => {
const name = getBatteryName(
this._states,
this._formatEntityName,
source,
this._data!.statsMetadata
);
return (
name &&
this._i18n.localize(
`ui.panel.lovelace.cards.energy.energy_sources_table.${key}`,
{ name }
)
);
};
const series = (statId?: string) => sumSeries([statId]).values;
const batteryOut = addGroup("battery", split.battery, (source, idx) => {
const flows = getBatteryFlowStatIds(source);
return addNode(
const batteryOut = addGroup("battery", split.battery, (source, idx) =>
addNode(
"battery",
getBatteryEnergyStatIds(source).discharge,
source.stat_energy_from,
"--energy-battery-out-color",
idx,
0,
batteryName(source, "named_battery_discharged"),
flows && {
home: series(flows.battery_to_home),
grid_return: series(flows.battery_to_grid),
}
);
});
const batteryIn = addGroup("battery_in", split.battery, (source, idx) => {
const flows = getBatteryFlowStatIds(source);
return addNode(
source.name &&
this._i18n.localize(
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_discharged",
{ name: source.name }
)
)
);
const batteryIn = addGroup("battery_in", split.battery, (source, idx) =>
addNode(
"battery_in",
getBatteryEnergyStatIds(source).charge,
source.stat_energy_to,
"--energy-battery-in-color",
idx,
1,
batteryName(source, "named_battery_charged"),
flows && {
grid: series(flows.grid_to_battery),
solar: series(flows.solar_to_battery),
}
);
});
source.name &&
this._i18n.localize(
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_charged",
{ name: source.name }
)
)
);
const gridIn = addGroup(
"grid",
split.grid?.filter((source) => source.stat_energy_from),
(source, idx) =>
addNode(
"grid",
[source.stat_energy_from!],
source.stat_energy_from!,
"--energy-grid-consumption-color",
idx,
0,
@@ -594,7 +554,7 @@ class HuiEnergySankeyCard
const solar = addGroup("solar", split.solar, (source, idx) =>
addNode(
"solar",
[source.stat_energy_from],
source.stat_energy_from,
"--energy-solar-color",
idx,
0,
@@ -607,7 +567,7 @@ class HuiEnergySankeyCard
(source, idx) =>
addNode(
"grid_return",
[source.stat_energy_to!],
source.stat_energy_to!,
"--energy-grid-return-color",
idx,
1,
@@ -637,57 +597,22 @@ class HuiEnergySankeyCard
"battery>grid_return": consumption.battery_to_grid,
"solar>grid_return": consumption.solar_to_grid,
};
const measuredFlows: Record<string, Record<number, number> | undefined> = {
"battery>home": summedData.battery_to_home,
"grid>battery_in": summedData.grid_to_battery,
"solar>battery_in": summedData.solar_to_battery,
"battery>grid_return": summedData.battery_to_grid,
};
const isSplit = (id: string) => groups[id][0]?.id !== id;
// Batteries with direction statistics get their own flows, the rest of
// a flow is split across the others
const shares = (id: string, t: number): SankeyFlowShare[] =>
groups[id]
.filter((node) => !node.measured)
.map(({ id: nodeId, values }) => [
nodeId,
values ? (values[t] ?? 0) : 1,
]);
const exact = (node: SourceNode, other: string, t: number) =>
Math.max(node.measured![other]?.[t] ?? 0, 0);
groups[id].map(({ id: nodeId, values }) => [
nodeId,
values ? (values[t] ?? 0) : 1,
]);
const links = typeLinks.flatMap((link) => {
if (!isSplit(link.source) && !isSplit(link.target)) {
return [link];
}
const key = `${link.source}>${link.target}`;
const flow = flows[key];
const measuredSources = groups[link.source].filter((n) => n.measured);
const measuredTargets = groups[link.target].filter((n) => n.measured);
const measuredFlow =
measuredSources.length || measuredTargets.length
? measuredFlows[key]
: undefined;
const flow = flows[`${link.source}>${link.target}`];
const splitLinks = new Map<string, Link & { value: number }>();
for (const t of summedData.timestamps) {
measuredSources.forEach((node) =>
addSplitSankeyLinks(
splitLinks,
exact(node, link.target, t),
[[node.id, 1]],
shares(link.target, t)
)
);
measuredTargets.forEach((node) =>
addSplitSankeyLinks(
splitLinks,
exact(node, link.source, t),
shares(link.source, t),
[[node.id, 1]]
)
);
for (const t of timestamps) {
addSplitSankeyLinks(
splitLinks,
(flow[t] ?? 0) - Math.max(measuredFlow?.[t] ?? 0, 0),
flow[t] ?? 0,
shares(link.source, t),
shares(link.target, t)
);
@@ -24,8 +24,6 @@ import {
import type { EnergyData, EnergySourceByType } from "../../../../data/energy";
import {
energySourcesByType,
getBatteryEnergyStatIds,
getBatteryName,
validateEnergyCollectionKey,
} from "../../../../data/energy";
import { EnergyCollectionController } from "../../../../data/energy-collection-controller";
@@ -184,8 +182,7 @@ export class HuiEnergySourcesTableCard
compareCost: number | null,
showCosts: boolean,
compare: boolean,
name?: string,
moreInfo = true
name?: string
) {
const displayPrecision =
type === "gas" && this._gasUnits?.[statId] === energyUnit
@@ -224,10 +221,10 @@ export class HuiEnergySourcesTableCard
return html`<tr
class="mdc-data-table__row ${classMap({
clickable: moreInfo && !isExternalStatistic(statId),
clickable: !isExternalStatistic(statId),
})}"
@click=${this._handleMoreInfo}
.entity=${moreInfo ? statId : undefined}
.entity=${statId}
>
<td class="mdc-data-table__cell cell-bullet">
<div
@@ -565,19 +562,6 @@ export class HuiEnergySourcesTableCard
};
};
const _sumStatData = (statIds: string[]) =>
statIds.reduce(
(sum, statId) => {
const data = _extractStatData(statId, null);
return {
hasData: sum.hasData || data.hasData,
energy: sum.energy + data.energy,
energyCompare: sum.energyCompare + data.energyCompare,
};
},
{ hasData: false, energy: 0, energyCompare: 0 }
);
const showOnlyTotals = this._config.show_only_totals;
const combine =
!showOnlyTotals &&
@@ -806,17 +790,16 @@ export class HuiEnergySourcesTableCard
})}
${_renderSimpleCategory("solar")}
${types.battery?.map((source, idx) => {
const { charge, discharge } = getBatteryEnergyStatIds(source);
const {
hasData: hasFromData,
energy: energyFrom,
energyCompare: energyFromCompare,
} = _sumStatData(discharge);
} = _extractStatData(source.stat_energy_from, null);
const {
hasData: hasToData,
energy: energyTo,
energyCompare: energyToCompare,
} = _sumStatData(charge);
} = _extractStatData(source.stat_energy_to, null);
if (!hasFromData && !hasToData) {
return nothing;
@@ -839,16 +822,10 @@ export class HuiEnergySourcesTableCard
return nothing;
}
const name = getBatteryName(
this._states,
this._formatEntityName,
source,
this._data!.statsMetadata
);
return html` ${this._renderRow(
computedStyles,
"battery_out",
discharge[0],
source.stat_energy_from,
idx,
energyFrom,
energyFromCompare,
@@ -857,17 +834,16 @@ export class HuiEnergySourcesTableCard
null,
showCosts,
compare,
name
source.name
? this._i18n.localize(
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_discharged",
{ name }
{ name: source.name }
)
: "",
discharge.length === 1
: ""
)}${this._renderRow(
computedStyles,
"battery_in",
charge[0],
source.stat_energy_to,
idx,
-energyTo,
-energyToCompare,
@@ -876,13 +852,12 @@ export class HuiEnergySourcesTableCard
null,
showCosts,
compare,
name
source.name
? this._i18n.localize(
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_charged",
{ name }
{ name: source.name }
)
: "",
charge.length === 1
: ""
)}`;
})}
${renderCombinedRow(
@@ -32,9 +32,6 @@ import type {
import {
computeConsumptionData,
energySourcesByType,
getBatteryEnergyStatIds,
getBatteryFlowStatIds,
getBatteryName,
getSuggestedPeriod,
getSummedData,
validateEnergyCollectionKey,
@@ -75,13 +72,6 @@ const colorPropertyMap = {
used_home: "--primary-color",
};
interface BatteryStatIds {
// Series of batteries with direction statistics, by series key
series: Record<"to_battery" | "from_battery", Record<string, string[]>>;
// Every statistic of a category, for combined sources
combined: Record<"to_battery" | "from_battery", string[]>;
}
const stackOrder = {
to_battery: 1,
to_grid: 2,
@@ -363,11 +353,6 @@ export class HuiEnergyUsageGraphCard
from_battery: {},
};
const batteryStatIds: BatteryStatIds = {
series: { to_battery: {}, from_battery: {} },
combined: { to_battery: [], from_battery: [] },
};
for (const source of energyData.prefs.energy_sources) {
if (source.type === "solar") {
if (statIds.solar) {
@@ -379,40 +364,23 @@ export class HuiEnergyUsageGraphCard
}
if (source.type === "battery") {
const { charge, discharge } = getBatteryEnergyStatIds(source);
const chargeKey = charge[0];
const dischargeKey = discharge[0];
if (statIds.to_battery) {
statIds.to_battery.push(chargeKey);
statIds.from_battery!.push(dischargeKey);
statIds.to_battery.push(source.stat_energy_to);
statIds.from_battery!.push(source.stat_energy_from);
} else {
statIds.to_battery = [chargeKey];
statIds.from_battery = [dischargeKey];
statIds.to_battery = [source.stat_energy_to];
statIds.from_battery = [source.stat_energy_from];
}
batteryStatIds.combined.to_battery.push(...charge);
batteryStatIds.combined.from_battery.push(...discharge);
const flows = getBatteryFlowStatIds(source);
if (flows) {
// Discharge shows only what reached the home
batteryStatIds.series.to_battery[chargeKey] = charge;
batteryStatIds.series.from_battery[dischargeKey] =
flows.battery_to_home ? [flows.battery_to_home] : [];
}
const name = getBatteryName(
this._states,
this._formatters.formatEntityName,
source,
energyData.statsMetadata
);
if (name) {
statLabels.to_battery[chargeKey] = this._i18n.localize(
if (source.name) {
statLabels.to_battery[source.stat_energy_to] = this._i18n.localize(
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_charged",
{ name }
);
statLabels.from_battery[dischargeKey] = this._i18n.localize(
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_discharged",
{ name }
{ name: source.name }
);
statLabels.from_battery[source.stat_energy_from] =
this._i18n.localize(
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_discharged",
{ name: source.name }
);
}
continue;
}
@@ -550,7 +518,6 @@ export class HuiEnergyUsageGraphCard
computedStyles,
typeLabels,
statLabels,
batteryStatIds,
trackY,
combineTypes,
true
@@ -580,7 +547,6 @@ export class HuiEnergyUsageGraphCard
computedStyles,
typeLabels,
statLabels,
batteryStatIds,
trackY,
combineTypes,
false
@@ -672,7 +638,6 @@ export class HuiEnergyUsageGraphCard
to_battery: Record<string, string>;
from_battery: Record<string, string>;
},
batteryStatIds: BatteryStatIds,
trackY: (v: number) => void,
combineTypes: Set<string>,
compare = false
@@ -703,24 +668,13 @@ export class HuiEnergyUsageGraphCard
statIds!.forEach((id) => {
sets[id] = {};
});
sumEnergyStatistics(
statistics,
batteryStatIds.combined[key] ?? statIds!
).forEach((stat) => {
sumEnergyStatistics(statistics, statIds!).forEach((stat) => {
sets[statIds![0]][stat.start] = stat.change!;
});
combinedData[key] = sets;
return;
}
statIds!.forEach((id) => {
const summedIds = batteryStatIds.series[key]?.[id];
if (summedIds) {
sets[id] = {};
sumEnergyStatistics(statistics, summedIds).forEach((stat) => {
sets[id][stat.start] = stat.change!;
});
return;
}
const stats = statistics[id];
if (!stats) {
sets[id] = {};
@@ -751,55 +705,30 @@ export class HuiEnergyUsageGraphCard
combinedData.used_solar = { used_solar: consumptionData.used_solar };
}
const measuredToHome = summedData.battery_to_home;
if (combinedData.from_battery) {
// Discharge that did not reach the home
const batteryNotUsed: Record<number, number> = {};
let bySource = combinedData.from_battery;
let used = consumptionData.used_battery;
if (measuredToHome && !combineTypes.has("battery")) {
// Measured batteries have their own bars, only the rest is estimated
bySource = Object.fromEntries(
Object.entries(bySource).filter(
([id]) => !(id in batteryStatIds.series.from_battery)
)
);
used = {};
for (const start of summedData.timestamps) {
used[start] =
consumptionData.used_battery[start] -
Math.max(measuredToHome[start] ?? 0, 0);
batteryNotUsed[start] =
(summedData.from_battery_totals?.[start] ?? 0) - used[start];
}
} else {
for (const start of summedData.timestamps) {
batteryNotUsed[start] =
(summedData.from_battery?.[start] ?? 0) -
consumptionData.used_battery[start];
}
for (const start of summedData.timestamps) {
batteryNotUsed[start] =
(summedData.from_battery?.[start] ?? 0) -
consumptionData.used_battery[start];
}
combinedData.used_battery = {
used_battery: buildCombinedUsed(bySource, batteryNotUsed, used) ?? {},
used_battery:
buildCombinedUsed(
combinedData.from_battery,
batteryNotUsed,
consumptionData.used_battery
) ?? {},
};
}
if (combinedData.from_grid && summedData.to_battery) {
let gridNotUsed = consumptionData.grid_to_battery;
if (measuredToHome) {
// Measured flows can leave grid import that no estimate covers
gridNotUsed = {};
for (const start of summedData.timestamps) {
gridNotUsed[start] =
(summedData.from_grid?.[start] ?? 0) -
consumptionData.used_grid[start];
}
}
combinedData.used_grid = {
used_grid:
buildCombinedUsed(
combinedData.from_grid,
gridNotUsed,
consumptionData.grid_to_battery,
consumptionData.used_grid
) ?? {},
};
@@ -16,21 +16,14 @@ import {
uiContext,
} from "../../../../data/context";
import type {
BatteryFlowStatIds,
EnergyData,
EnergyPreferences,
EnergySourceByType,
MeasuredBatteryFlows,
} from "../../../../data/energy";
import {
BATTERY_FLOW_KEYS,
computeConsumptionSingle,
computeEnergyDeviceLabels,
energySourcesByType,
formatPowerShort,
getBatteryFlowPowerIds,
getBatteryName,
getBatteryPrimaryStat,
getPowerFromState,
validateEnergyCollectionKey,
} from "../../../../data/energy";
@@ -83,18 +76,8 @@ interface PowerData {
used_grid: number;
used_battery: number;
used_total: number;
// Live flows of the batteries with direction sensors
measured?: MeasuredBatteryFlows;
}
// Type links that a battery with direction sensors measures
const MEASURED_LINKS: Record<string, keyof BatteryFlowStatIds> = {
"battery>home": "battery_to_home",
"battery>grid_return": "battery_to_grid",
"grid>battery_in": "grid_to_battery",
"solar>battery_in": "solar_to_battery",
};
@customElement("hui-power-sankey-card")
class HuiPowerSankeyCard
extends MobileAwareMixin(LitElement)
@@ -131,22 +114,11 @@ class HuiPowerSankeyCard
const prefs = this._data?.prefs;
if (states && prefs) {
[
...prefs.energy_sources.flatMap((source) => {
if (source.type === "gas" || source.type === "water") {
return [];
}
if (source.type === "battery") {
const rates = getBatteryFlowPowerIds(source);
if (rates) {
return [
...Object.values(rates),
// Names a battery that has no name of its own
source.name ? undefined : getBatteryPrimaryStat(source),
];
}
}
return [source.stat_rate];
}),
...prefs.energy_sources.map((source) =>
source.type === "gas" || source.type === "water"
? undefined
: source.stat_rate
),
...prefs.device_consumption.map((device) => device.stat_rate),
].forEach((entityId) => {
if (entityId && states[entityId]) {
@@ -272,7 +244,6 @@ class HuiPowerSankeyCard
typeLinks.push({
source: "battery",
target: "home",
value: powerData.used_battery,
});
}
@@ -292,14 +263,12 @@ class HuiPowerSankeyCard
typeLinks.push({
source: "grid",
target: "battery_in",
value: powerData.grid_to_battery,
});
}
if (powerData.solar_to_battery > 0) {
typeLinks.push({
source: "solar",
target: "battery_in",
value: powerData.solar_to_battery,
});
}
}
@@ -320,7 +289,6 @@ class HuiPowerSankeyCard
typeLinks.push({
source: "grid",
target: "home",
value: powerData.used_grid,
});
}
@@ -338,7 +306,6 @@ class HuiPowerSankeyCard
typeLinks.push({
source: "solar",
target: "home",
value: powerData.used_solar,
});
}
@@ -359,14 +326,12 @@ class HuiPowerSankeyCard
typeLinks.push({
source: "battery",
target: "grid_return",
value: powerData.battery_to_grid,
});
}
if (powerData.solar_to_grid > 0) {
typeLinks.push({
source: "solar",
target: "grid_return",
value: powerData.solar_to_grid,
});
}
}
@@ -391,7 +356,6 @@ class HuiPowerSankeyCard
[homeNode, ...typeNodes].filter((node) => node.value > 0),
typeLinks
),
powerData.measured,
computedStyle
);
nodes.push(...separate.nodes);
@@ -493,25 +457,17 @@ class HuiPowerSankeyCard
split: EnergySourceByType,
typeNodes: Node[],
typeLinks: (Link & { value: number })[],
measured: MeasuredBatteryFlows | undefined,
computedStyle: CSSStyleDeclaration
): { nodes: Node[]; links: Link[] } {
const nodes: Node[] = [];
const flowPower = (entityId?: string) =>
entityId ? Math.max(this._getCurrentPower(entityId), 0) : 0;
const flowIds = (...ids: (string | undefined)[]) =>
ids.filter((id): id is string => !!id);
// Nodes of batteries with direction sensors get exact links instead of a
// share of the type links
const addNodes = <T extends { stat_rate?: string; name?: string }>(
type: string,
sources: T[] | undefined,
sign: 1 | -1,
colorVariable: string,
index: number,
getName: (source: T) => string | undefined,
getFlowIds?: (source: T) => string[] | undefined
getName: (source: T) => string | undefined
): SankeyFlowShare[] => {
if (!sources) {
nodes.push(...typeNodes.filter((node) => node.id === type));
@@ -519,18 +475,14 @@ class HuiPowerSankeyCard
}
const shares: SankeyFlowShare[] = [];
sources.forEach((source, idx) => {
const ids = getFlowIds?.(source);
const statId = ids ? ids[0] : source.stat_rate;
if (!statId) {
if (!source.stat_rate) {
return;
}
const value = ids
? ids.reduce((sum, id) => sum + flowPower(id), 0)
: sign * this._getCurrentPower(statId);
const value = sign * this._getCurrentPower(source.stat_rate);
if (value <= 0) {
return;
}
const id = `${type}:${statId}`;
const id = `${type}:${source.stat_rate}`;
nodes.push({
id,
label:
@@ -538,8 +490,8 @@ class HuiPowerSankeyCard
getStatisticLabel(
this._states,
this._formatEntityName,
statId,
this._data!.statsMetadata[statId]
source.stat_rate,
this._data!.statsMetadata[source.stat_rate]
),
value,
color: getEnergyColor(
@@ -553,34 +505,21 @@ class HuiPowerSankeyCard
index,
group: type,
entityId:
(!ids || ids.length === 1) && statId in this._states
? statId
: undefined,
source.stat_rate in this._states ? source.stat_rate : undefined,
});
if (!ids) {
shares.push([id, value]);
}
shares.push([id, value]);
});
return shares;
};
const batteryName =
(key: "named_battery_discharged" | "named_battery_charged") =>
(source: NonNullable<EnergySourceByType["battery"]>[number]) => {
const name = getBatteryName(
this._states,
this._formatEntityName,
source,
this._data!.statsMetadata
(source: { name?: string }) =>
source.name &&
this._i18n.localize(
`ui.panel.lovelace.cards.energy.energy_sources_table.${key}`,
{ name: source.name }
);
return (
name &&
this._i18n.localize(
`ui.panel.lovelace.cards.energy.energy_sources_table.${key}`,
{ name }
)
);
};
const batteryOut = addNodes(
"battery",
@@ -588,11 +527,7 @@ class HuiPowerSankeyCard
1,
"--energy-battery-out-color",
0,
batteryName("named_battery_discharged"),
(source) => {
const rates = getBatteryFlowPowerIds(source);
return rates && flowIds(rates.battery_to_home, rates.battery_to_grid);
}
batteryName("named_battery_discharged")
);
const batteryIn = addNodes(
"battery_in",
@@ -600,11 +535,7 @@ class HuiPowerSankeyCard
-1,
"--energy-battery-in-color",
1,
batteryName("named_battery_charged"),
(source) => {
const rates = getBatteryFlowPowerIds(source);
return rates && flowIds(rates.grid_to_battery, rates.solar_to_battery);
}
batteryName("named_battery_charged")
);
const gridIn = addNodes(
"grid",
@@ -661,54 +592,15 @@ class HuiPowerSankeyCard
batteryOut,
batteryIn
);
split.battery.forEach((source) => {
const rates = getBatteryFlowPowerIds(source);
if (!rates) {
return;
}
const outId = flowIds(rates.battery_to_home, rates.battery_to_grid)[0];
const inId = flowIds(rates.grid_to_battery, rates.solar_to_battery)[0];
if (outId) {
const out: SankeyFlowShare[] = [[`battery:${outId}`, 1]];
addSplitSankeyLinks(
links,
flowPower(rates.battery_to_home),
out,
sharesByNode.home
);
addSplitSankeyLinks(
links,
flowPower(rates.battery_to_grid),
out,
sharesByNode.grid_return
);
}
if (inId) {
const into: SankeyFlowShare[] = [[`battery_in:${inId}`, 1]];
addSplitSankeyLinks(
links,
flowPower(rates.grid_to_battery),
sharesByNode.grid,
into
);
addSplitSankeyLinks(
links,
flowPower(rates.solar_to_battery),
sharesByNode.solar,
into
);
}
});
}
typeLinks.forEach((link) => {
const flow = MEASURED_LINKS[`${link.source}>${link.target}`];
typeLinks.forEach((link) =>
addSplitSankeyLinks(
links,
link.value - (split.battery && flow ? (measured?.[flow] ?? 0) : 0),
link.value,
sharesByNode[link.source],
sharesByNode[link.target]
);
});
)
);
return { nodes, links: [...links.values()] };
}
@@ -753,32 +645,14 @@ class HuiPowerSankeyCard
// Collect battery power (positive = discharge, negative = charge)
// Sum all battery values first, then determine net direction.
// Momentary power should only flow in one direction across all batteries.
// Batteries with direction sensors are measured instead.
let net_battery = 0;
let hasMeasured = false;
const measured: Record<keyof BatteryFlowStatIds, number> = {
grid_to_battery: 0,
solar_to_battery: 0,
battery_to_home: 0,
battery_to_grid: 0,
};
prefs.energy_sources.forEach((source) => {
if (source.type !== "battery") {
return;
}
const rates = getBatteryFlowPowerIds(source);
if (rates) {
hasMeasured = true;
BATTERY_FLOW_KEYS.forEach((key) => {
const entityId = rates[key];
if (entityId) {
measured[key] += Math.max(this._getCurrentPower(entityId), 0);
}
});
} else if (source.stat_rate) {
net_battery += this._getCurrentPower(source.stat_rate);
}
});
prefs.energy_sources
.filter((source) => source.type === "battery")
.forEach((source) => {
if (source.type === "battery" && source.stat_rate) {
net_battery += this._getCurrentPower(source.stat_rate);
}
});
if (net_battery > 0) {
from_battery = net_battery;
} else if (net_battery < 0) {
@@ -791,33 +665,75 @@ class HuiPowerSankeyCard
const homePower = homeStatRate
? getPowerFromState(this._states[homeStatRate])
: undefined;
const used_total =
homePower ?? from_grid + solar + from_battery - to_grid - to_battery;
const hasGridPower = gridValues.length > 0;
const hasSolarPower = prefs.energy_sources.some(
(source) => source.type === "solar" && source.stat_rate
);
const flows = computeConsumptionSingle(
{
from_grid: hasGridPower ? from_grid : undefined,
to_grid: hasGridPower ? to_grid : undefined,
solar: hasSolarPower ? solar : undefined,
to_battery,
from_battery,
},
{ measured: hasMeasured ? measured : undefined, used_total: homePower }
// Determine power routing using priority logic
// Priority: Solar -> Battery_In, Solar -> Grid_Out, Battery_Out -> Grid_Out,
// Grid_In -> Battery_In, Solar -> Consumption, Battery_Out -> Consumption, Grid_In -> Consumption
let solar_remaining = solar;
let grid_remaining = from_grid;
let battery_remaining = from_battery;
let to_battery_remaining = to_battery;
let to_grid_remaining = to_grid;
let used_total_remaining = Math.max(used_total, 0);
let grid_to_battery = 0;
// Handle excess grid input to battery first
const excess_grid_in_after_consumption = Math.max(
0,
Math.min(to_battery_remaining, grid_remaining - used_total_remaining)
);
grid_to_battery += excess_grid_in_after_consumption;
to_battery_remaining -= excess_grid_in_after_consumption;
grid_remaining -= excess_grid_in_after_consumption;
// Solar -> Battery_In
const solar_to_battery = Math.min(solar_remaining, to_battery_remaining);
to_battery_remaining -= solar_to_battery;
solar_remaining -= solar_to_battery;
// Solar -> Grid_Out
const solar_to_grid = Math.min(solar_remaining, to_grid_remaining);
to_grid_remaining -= solar_to_grid;
solar_remaining -= solar_to_grid;
// Battery_Out -> Grid_Out
const battery_to_grid = Math.min(battery_remaining, to_grid_remaining);
battery_remaining -= battery_to_grid;
// Grid_In -> Battery_In (second pass)
const grid_to_battery_2 = Math.min(grid_remaining, to_battery_remaining);
grid_to_battery += grid_to_battery_2;
grid_remaining -= grid_to_battery_2;
// Solar -> Consumption
const used_solar = Math.min(used_total_remaining, solar_remaining);
used_total_remaining -= used_solar;
// Battery_Out -> Consumption
const used_battery = Math.min(battery_remaining, used_total_remaining);
used_total_remaining -= used_battery;
// Grid_In -> Consumption
const used_grid = Math.min(used_total_remaining, grid_remaining);
return {
...flows,
solar,
from_grid,
to_grid,
from_battery:
from_battery + measured.battery_to_home + measured.battery_to_grid,
to_battery:
to_battery + measured.grid_to_battery + measured.solar_to_battery,
used_total: Math.max(0, flows.used_total),
measured: hasMeasured ? measured : undefined,
from_battery,
to_battery,
grid_to_battery,
battery_to_grid,
solar_to_battery,
solar_to_grid,
used_solar,
used_grid,
used_battery,
used_total: Math.max(0, used_total),
};
}
@@ -248,7 +248,7 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
}
try {
this._entry = await getExtendedEntityRegistryEntry(
this._api.callWS,
this._api,
this._config!.entity
);
} catch (_e) {
@@ -44,7 +44,6 @@ import {
formatDateMonth,
formatDateMonthShort,
formatDateVeryShort,
formatDateWeekdayVeryShortDate,
formatDateYear,
} from "../../../common/datetime/format_date";
import { consume } from "../../../common/decorators/consume";
@@ -368,7 +367,7 @@ export class HuiEnergyPeriodSelector extends LitElement {
this._hassConfig
)}`
: simpleRange === "day"
? html`${formatDateWeekdayVeryShortDate(
? html`${formatDateVeryShort(
this._startDate,
this._i18n.locale,
this._hassConfig
@@ -1,6 +1,6 @@
import type { ContextType } from "@lit/context";
import { mdiDelete, mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { consume } from "../../../common/decorators/consume";
@@ -30,9 +30,19 @@ import "../../../components/ha-settings-row";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import { computeDefaultSecurityAlertSeverity } from "../strategies/security-alerts";
import { createSecurityEntityFilter } from "../security-entity-filter";
import {
createSecurityEntityFilter,
type SecurityEntityContext,
} from "../security-entity-filter";
import type { ValueChangedEvent } from "../../../types";
type SecurityEditorContext = SecurityEntityContext & {
language: ContextType<typeof internationalizationContext>["language"];
translationMetadata: ContextType<
typeof internationalizationContext
>["translationMetadata"];
};
@customElement("security-alerts-editor")
export class SecurityAlertsEditor extends LitElement {
@property({ attribute: false })
@@ -50,6 +60,24 @@ export class SecurityAlertsEditor extends LitElement {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _entityContext?: SecurityEditorContext;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
changedProps.has("_states") ||
changedProps.has("_registries") ||
changedProps.has("_i18n")
) {
this._entityContext = {
states: this._states,
...this._registries,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
};
}
}
protected render() {
return html`
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
@@ -79,19 +107,10 @@ export class SecurityAlertsEditor extends LitElement {
index: number
) {
const stateObj = this._states[alertEntity.entity];
const { primary, secondary } = stateObj
? computeEntityPickerDisplay(
{
entities: this._registries.entities,
devices: this._registries.devices,
areas: this._registries.areas,
floors: this._registries.floors,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
stateObj
)
: { primary: alertEntity.entity, secondary: undefined };
const { primary, secondary } =
stateObj && this._entityContext
? computeEntityPickerDisplay(this._entityContext, stateObj)
: { primary: alertEntity.entity, secondary: undefined };
const severity =
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
@@ -139,10 +158,7 @@ export class SecurityAlertsEditor extends LitElement {
fireEvent(this, "value-changed", { value: next });
}
private _entityFilter = createSecurityEntityFilter(
() => this._states,
() => this._registries
);
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
private _getIndex(
ev: HASSDomCurrentTargetEvent<HTMLElement>
@@ -1,5 +1,5 @@
import type { ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../common/decorators/consume";
import "../../../components/ha-button";
@@ -19,7 +19,10 @@ import { haStyleDialog } from "../../../resources/styles";
import type { ValueChangedEvent } from "../../../types";
import "../../../components/entity/ha-favorites-editor";
import "../components/security-alerts-editor";
import { createSecurityEntityFilter } from "../security-entity-filter";
import {
createSecurityEntityFilter,
type SecurityEntityContext,
} from "../security-entity-filter";
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
@customElement("dialog-edit-security")
@@ -34,12 +37,26 @@ export class DialogEditSecurity extends DirtyStateProviderMixin<SecurityFrontend
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
private _entityContext?: SecurityEntityContext;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("_states") || changedProps.has("_registries")) {
this._entityContext = {
states: this._states,
...this._registries,
};
}
}
public connectedCallback(): void {
super.connectedCallback();
if (!this.params) {
@@ -162,10 +179,7 @@ export class DialogEditSecurity extends DirtyStateProviderMixin<SecurityFrontend
this._updateDirtyState(this._state);
}
private _entityFilter = createSecurityEntityFilter(
() => this._states,
() => this._registries
);
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
private async _save(): Promise<void> {
if (!this.params || !this._state) return;
+10 -18
View File
@@ -1,23 +1,15 @@
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistantRegistries } from "../../types";
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { registriesContext, statesContext } from "../../data/context";
import { isSecurityPanelEntity } from "./strategies/security-view-strategy";
export type SecurityEntityContext = ContextType<typeof registriesContext> & {
states: ContextType<typeof statesContext>;
};
export const createSecurityEntityFilter =
(
getStates: () => HassEntities | null | undefined,
getRegistries: () => HomeAssistantRegistries | null | undefined
) =>
(getContext: () => SecurityEntityContext | undefined) =>
(entity: HassEntity): boolean => {
const states = getStates();
const registries = getRegistries();
return states && registries
? isSecurityPanelEntity(
states,
registries.entities,
registries.devices,
registries.areas,
registries.floors,
entity
)
: false;
const context = getContext();
return context ? isSecurityPanelEntity(context, entity) : false;
};
@@ -83,20 +83,16 @@ export const findSecurityEntities = (sources: EntityFilterSources): string[] =>
findFilteredEntities(sources, securityEntityFilters);
export const isSecurityPanelEntity = (
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
hass: EntityFilterSources,
stateObj: HassEntity
): boolean =>
securityEntityFilters.some((filter) =>
generateEntityFilter(
states,
entities,
devices,
areas,
floors,
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)(stateObj.entity_id)
);
@@ -192,14 +188,7 @@ export class SecurityViewStrategy extends ReactiveElement {
const favoriteEntities = (config.favorite_entities ?? []).filter(
(entityId) =>
hass.states[entityId] &&
isSecurityPanelEntity(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
hass.states[entityId]
)
isSecurityPanelEntity(hass, hass.states[entityId])
);
if (favoriteEntities.length > 0) {
+1 -1
View File
@@ -358,7 +358,7 @@ class PanelTodo extends LitElement {
}
const entityRegistryEntry = await getExtendedEntityRegistryEntry(
this.hass.callWS,
this.hass,
this._entityId
);
+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.",
@@ -1,48 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import { computeTimelineColor } from "../../../src/components/chart/timeline-color";
import { createMockComputedStyle } from "../../fixtures/computed-style";
const entity = (
entity_id: string,
attributes: Record<string, unknown> = {}
): HassEntity =>
({
entity_id,
state: "",
attributes,
last_changed: "",
last_updated: "",
context: { id: "", parent_id: null, user_id: null },
}) as HassEntity;
describe("computeTimelineColor", () => {
it("uses a themed color for a custom state of an uncolored domain", () => {
const styles = createMockComputedStyle({
"--state-sensor-heating-color": "#ff0000",
"--state-sensor-power-heating-color": "#00ff00",
});
expect(
computeTimelineColor("Heating", styles, entity("sensor.heat_pump"))
).toBe("#ff0000");
expect(
computeTimelineColor(
"Heating",
styles,
entity("sensor.heat_pump", { device_class: "power" })
)
).toBe("#00ff00");
});
it("keeps active/inactive fallbacks away from uncolored domains", () => {
const styles = createMockComputedStyle({
"--state-active-color": "#123456",
"--state-sensor-active-color": "#654321",
});
expect(
computeTimelineColor("Heating", styles, entity("sensor.heat_pump"))
).not.toMatch(/^#(123456|654321)$/);
});
});
File diff suppressed because it is too large. Load diff
-64
View File
@@ -40,18 +40,6 @@ describe("getSummedData characterization", () => {
expect(getSummedData(data)).toMatchSnapshot();
});
it("matches snapshot for a battery measured per direction", () => {
const data = generateEnergyData(8, {
days: 1,
prefs: generateEnergyPreferences({
grid: true,
solar: true,
batteryDirections: true,
}),
});
expect(getSummedData(data)).toMatchSnapshot();
});
it("digest is stable for a month of 5-minute data", () => {
const data = generateEnergyData(4, { days: 31, period: "hour" });
expect(digestResult(getSummedData(data).summedData)).toMatchSnapshot();
@@ -73,36 +61,6 @@ describe("computeConsumptionData characterization", () => {
).toMatchSnapshot();
});
it("matches snapshot for a battery measured per direction", () => {
const data = generateEnergyData(8, {
days: 1,
prefs: generateEnergyPreferences({
grid: true,
solar: true,
batteryDirections: true,
}),
});
const { summedData } = getSummedData(data);
expect(computeConsumptionData(summedData, undefined)).toMatchSnapshot();
});
it("matches snapshot for mixed batteries with compare data", () => {
const data = generateEnergyData(9, {
days: 1,
compare: true,
prefs: generateEnergyPreferences({
grid: true,
solar: true,
battery: true,
batteryDirections: true,
}),
});
const { summedData, compareSummedData } = getSummedData(data);
expect(
computeConsumptionData(summedData, compareSummedData)
).toMatchSnapshot();
});
it("digest is stable for a month of hourly data", () => {
const data = generateEnergyData(7, { days: 31, period: "hour" });
const { summedData } = getSummedData(data);
@@ -149,28 +107,6 @@ describe("computeConsumptionSingle characterization", () => {
).toMatchSnapshot();
});
it("handles mixed batteries with measured flows", () => {
expect(
computeConsumptionSingle(
{
from_grid: 4.2,
to_grid: 1.3,
solar: 3.7,
to_battery: 0.8,
from_battery: 1.1,
},
{
measured: {
grid_to_battery: 1.4,
solar_to_battery: 0.6,
battery_to_home: 0.9,
battery_to_grid: 0.2,
},
}
)
).toMatchSnapshot();
});
it("handles battery charged from grid", () => {
expect(
computeConsumptionSingle({
+3 -305
View File
@@ -49,8 +49,7 @@ const checkConsumptionResult = (
to_battery: number | undefined;
from_battery: number | undefined;
},
exact = true,
options?: Parameters<typeof computeConsumptionSingle>[1]
exact = true
): {
grid_to_battery: number;
battery_to_grid: number;
@@ -61,9 +60,8 @@ const checkConsumptionResult = (
used_battery: number;
used_total: number;
} => {
const result = computeConsumptionSingle(input, options);
const result = computeConsumptionSingle(input);
if (exact) {
const measured = options?.measured;
assert.equal(
result.used_total,
result.used_solar + result.used_battery + result.used_grid
@@ -73,25 +71,13 @@ const checkConsumptionResult = (
result.solar_to_grid + result.battery_to_grid
);
assert.equal(
(input.to_battery || 0) +
(measured?.grid_to_battery || 0) +
(measured?.solar_to_battery || 0),
input.to_battery || 0,
result.grid_to_battery + result.solar_to_battery
);
assert.equal(
input.solar || 0,
result.solar_to_battery + result.solar_to_grid + result.used_solar
);
assert.equal(
input.from_grid || 0,
result.used_grid + result.grid_to_battery
);
assert.equal(
(input.from_battery || 0) +
(measured?.battery_to_home || 0) +
(measured?.battery_to_grid || 0),
result.used_battery + result.battery_to_grid
);
}
return result;
};
@@ -663,294 +649,6 @@ describe("Energy Usage Calculation Tests", () => {
});
});
describe("Measured battery flows", () => {
const noTotals = { to_battery: undefined, from_battery: undefined };
it("keeps grid charging that an estimate would call solar charging", () => {
assert.deepEqual(
checkConsumptionResult(
{ from_grid: 6, to_grid: 3, solar: 8, ...noTotals },
true,
{
measured: {
grid_to_battery: 2,
solar_to_battery: 3,
battery_to_home: 4,
battery_to_grid: 1,
},
}
),
{
grid_to_battery: 2,
battery_to_grid: 1,
solar_to_battery: 3,
solar_to_grid: 2,
used_solar: 3,
used_grid: 4,
used_battery: 4,
used_total: 11,
}
);
});
it("estimates only the batteries configured with totals", () => {
assert.deepEqual(
checkConsumptionResult(
{ from_grid: 5, to_grid: 1, solar: 4, to_battery: 1, from_battery: 2 },
true,
{
measured: {
grid_to_battery: 2,
solar_to_battery: 0,
battery_to_home: 1,
battery_to_grid: 0,
},
}
),
{
grid_to_battery: 2,
battery_to_grid: 0,
solar_to_battery: 1,
solar_to_grid: 1,
used_solar: 2,
used_grid: 3,
used_battery: 3,
used_total: 8,
}
);
});
it("takes the home reading as the total and leaves out battery to home", () => {
assert.deepEqual(
computeConsumptionSingle(
{ from_grid: 6, to_grid: 3, solar: 8, ...noTotals },
{
measured: {
grid_to_battery: 2,
solar_to_battery: 3,
battery_to_home: 4,
battery_to_grid: 1,
},
used_total: 10,
}
),
{
grid_to_battery: 2,
battery_to_grid: 1,
solar_to_battery: 3,
solar_to_grid: 2,
used_solar: 3,
used_grid: 3,
used_battery: 4,
used_total: 10,
}
);
});
it("keeps solar charging without a solar source out of the home load", () => {
assert.deepEqual(
computeConsumptionSingle(
{ from_grid: 5, to_grid: 0, solar: undefined, ...noTotals },
{
measured: {
grid_to_battery: 0,
solar_to_battery: 2,
battery_to_home: 1,
battery_to_grid: 0,
},
}
),
{
grid_to_battery: 0,
battery_to_grid: 0,
solar_to_battery: 2,
solar_to_grid: 0,
used_solar: 0,
used_grid: 5,
used_battery: 1,
used_total: 6,
}
);
});
it("keeps grid charging out of the home load without a grid import meter", () => {
const { consumption } = computeConsumptionData(
{
solar: { 1: 3 },
grid_to_battery: { 1: 2 },
solar_to_battery: { 1: 0 },
battery_to_home: { 1: 1 },
battery_to_grid: { 1: 0 },
to_battery_totals: {},
from_battery_totals: {},
total: {},
timestamps: [1],
},
undefined
);
assert.equal(consumption.used_total[1], 4);
assert.equal(consumption.used_solar[1], 3);
assert.equal(consumption.grid_to_battery[1], 2);
});
it("keeps battery to grid without an export meter out of the home load", () => {
assert.deepEqual(
computeConsumptionSingle(
{ from_grid: 3, to_grid: undefined, solar: 0, ...noTotals },
{
measured: {
grid_to_battery: 0,
solar_to_battery: 0,
battery_to_home: 0,
battery_to_grid: 1,
},
}
),
{
grid_to_battery: 0,
battery_to_grid: 1,
solar_to_battery: 0,
solar_to_grid: 0,
used_solar: 0,
used_grid: 3,
used_battery: 0,
used_total: 3,
}
);
});
it("keeps one battery charging another as battery to home", () => {
assert.deepEqual(
checkConsumptionResult(
{ from_grid: 3, to_grid: 0, solar: 0, to_battery: 2, from_battery: 0 },
true,
{
measured: {
grid_to_battery: 0,
solar_to_battery: 0,
battery_to_home: 2,
battery_to_grid: 0,
},
}
),
{
grid_to_battery: 2,
battery_to_grid: 0,
solar_to_battery: 0,
solar_to_grid: 0,
used_solar: 0,
used_grid: 1,
used_battery: 2,
used_total: 3,
}
);
});
it("never lowers grid charging above the grid import", () => {
assert.deepEqual(
computeConsumptionSingle(
{ from_grid: 2, to_grid: 0, solar: 4, ...noTotals },
{
measured: {
grid_to_battery: 3,
solar_to_battery: 0,
battery_to_home: 0,
battery_to_grid: 0,
},
}
),
{
grid_to_battery: 3,
battery_to_grid: 0,
solar_to_battery: 0,
solar_to_grid: 0,
used_solar: 3,
used_grid: 0,
used_battery: 0,
used_total: 3,
}
);
});
});
describe("computeConsumptionSingle on live power", () => {
// The power Sankey card's own copy of the estimate, before it called
// computeConsumptionSingle
const legacyPowerFlows = (
{ solar, from_grid, to_grid, from_battery, to_battery },
homePower: number | undefined
) => {
const used_total =
homePower ?? from_grid + solar + from_battery - to_grid - to_battery;
let solar_remaining = solar;
let grid_remaining = from_grid;
let battery_remaining = from_battery;
let to_battery_remaining = to_battery;
let to_grid_remaining = to_grid;
let used_total_remaining = Math.max(used_total, 0);
let grid_to_battery = 0;
const excess = Math.max(
0,
Math.min(to_battery_remaining, grid_remaining - used_total_remaining)
);
grid_to_battery += excess;
to_battery_remaining -= excess;
grid_remaining -= excess;
const solar_to_battery = Math.min(solar_remaining, to_battery_remaining);
to_battery_remaining -= solar_to_battery;
solar_remaining -= solar_to_battery;
const solar_to_grid = Math.min(solar_remaining, to_grid_remaining);
to_grid_remaining -= solar_to_grid;
solar_remaining -= solar_to_grid;
const battery_to_grid = Math.min(battery_remaining, to_grid_remaining);
battery_remaining -= battery_to_grid;
const grid_to_battery_2 = Math.min(grid_remaining, to_battery_remaining);
grid_to_battery += grid_to_battery_2;
grid_remaining -= grid_to_battery_2;
const used_solar = Math.min(used_total_remaining, solar_remaining);
used_total_remaining -= used_solar;
const used_battery = Math.min(battery_remaining, used_total_remaining);
used_total_remaining -= used_battery;
const used_grid = Math.min(used_total_remaining, grid_remaining);
return {
grid_to_battery,
battery_to_grid,
solar_to_battery,
solar_to_grid,
used_solar,
used_grid,
used_battery,
used_total: Math.max(0, used_total),
};
};
it("matches the card's former estimate, with and without a home sensor", () => {
const watts = [0, 150, 1234.5, 4000];
for (const solar of watts) {
for (const grid of [-2500, -150, ...watts]) {
for (const battery of [-3000, -200, ...watts]) {
for (const homePower of [undefined, 0, 900, 5000]) {
const input = {
solar,
from_grid: Math.max(grid, 0),
to_grid: Math.max(-grid, 0),
from_battery: Math.max(battery, 0),
to_battery: Math.max(-battery, 0),
};
const flows = computeConsumptionSingle(input, {
used_total: homePower,
});
assert.deepEqual(
{ ...flows, used_total: Math.max(0, flows.used_total) },
legacyPowerFlows(input, homePower)
);
}
}
}
}
});
});
describe("Self-consumed solar gauge tests", () => {
it("no battery", () => {
const hasBattery = false;
+1 -14
View File
@@ -10,7 +10,6 @@ import type {
EnergyPreferences,
EnergySource,
} from "../../src/data/energy";
import { getBatteryEnergyStatIds } from "../../src/data/energy";
import { FIXED_EPOCH_MS } from "./history-states";
import { generateStatistics } from "./statistics";
@@ -18,8 +17,6 @@ export interface EnergyPreferencesOptions {
grid?: boolean;
solar?: boolean;
battery?: boolean;
/** A battery measured per direction, after the one with totals */
batteryDirections?: boolean;
gas?: boolean;
water?: boolean;
}
@@ -62,15 +59,6 @@ export const generateEnergyPreferences = (
stat_energy_to: "sensor.battery_charge",
});
}
if (options.batteryDirections) {
sources.push({
type: "battery",
stat_energy_from_grid: "sensor.powerwall_from_grid",
stat_energy_from_solar: "sensor.powerwall_from_solar",
stat_energy_to_home: "sensor.powerwall_to_home",
stat_energy_to_grid: "sensor.powerwall_to_grid",
});
}
if (options.gas) {
sources.push({
type: "gas",
@@ -113,8 +101,7 @@ const statisticIdsForPrefs = (prefs: EnergyPreferences): string[] => {
if (source.stat_energy_from) ids.push(source.stat_energy_from);
if (source.stat_energy_to) ids.push(source.stat_energy_to);
} else if (source.type === "battery") {
const { charge, discharge } = getBatteryEnergyStatIds(source);
ids.push(...discharge, ...charge);
ids.push(source.stat_energy_from, source.stat_energy_to);
} else if (source.type === "home") {
ids.push(source.stat_energy_to);
} else {
-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,
@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest";
import { createSecurityEntityFilter } from "../../../src/panels/security/security-entity-filter";
import { createMockEntityState, createMockHass } from "../../fixtures/hass";
describe("createSecurityEntityFilter", () => {
const hass = createMockHass();
const stateObj = createMockEntityState("binary_sensor.glass_break", "off", {
device_class: "glass_break",
});
hass.states[stateObj.entity_id] = stateObj;
const registries = {
entities: hass.entities,
devices: hass.devices,
areas: hass.areas,
floors: hass.floors,
};
it("reads the latest states and registries", () => {
let states: typeof hass.states | null = null;
const filter = createSecurityEntityFilter(
() => states,
() => registries
);
expect(filter(stateObj)).toBe(false);
states = hass.states;
expect(filter(stateObj)).toBe(true);
});
it("rejects entities without registries", () => {
const filter = createSecurityEntityFilter(
() => hass.states,
() => null
);
expect(filter(stateObj)).toBe(false);
});
});
@@ -13,16 +13,7 @@ describe("security-view-strategy", () => {
});
hass.states[stateObj.entity_id] = stateObj;
expect(
isSecurityPanelEntity(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
stateObj
)
).toBe(true);
expect(isSecurityPanelEntity(hass, stateObj)).toBe(true);
});
it("renders active alerts as individual cards in a visible section", async () => {