mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-15 17:32:58 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2fe8a2f6e | ||
|
|
0d31ae5624 | ||
|
|
f57384a265 | ||
|
|
4fe20d7712 | ||
|
|
14a4c05130 | ||
|
|
2d6ebd26d9 | ||
|
|
5ba1190cdd | ||
|
|
81cf8050e4 | ||
|
|
1a10982a5e | ||
|
|
fb8ac756ce | ||
|
|
2c072ab254 | ||
|
|
31ca8c298b | ||
|
|
1a5d8884ad | ||
|
|
9a95c9dea9 | ||
|
|
5db5f9075d | ||
|
|
6ce043a8e4 | ||
|
|
d81a9f29b2 | ||
|
|
c39734f350 | ||
|
|
e65584e724 | ||
|
|
6641a43e43 | ||
|
|
2706bd89a1 | ||
|
|
bd434eeb0d | ||
|
|
16f01e1081 | ||
|
|
050251db23 | ||
|
|
13128609de | ||
|
|
0f70813ada | ||
|
|
5762da9d22 | ||
|
|
33f709cd21 | ||
|
|
1f77e4cd59 |
No files matched your search
@@ -28,7 +28,7 @@ class HcLaunchScreen extends LitElement {
|
||||
:host {
|
||||
display: block;
|
||||
height: 100vh;
|
||||
background-color: #f2f4f9;
|
||||
background-color: var(--primary-background-color, #f2f4f9);
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
}
|
||||
.container {
|
||||
@@ -44,7 +44,7 @@ class HcLaunchScreen extends LitElement {
|
||||
object-fit: cover;
|
||||
}
|
||||
.status {
|
||||
color: #1d2126;
|
||||
color: var(--primary-text-color, #1d2126);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("energy/get_prefs", (): EnergyPreferences => ({
|
||||
const prefs: EnergyPreferences = {
|
||||
energy_sources: [
|
||||
{
|
||||
type: "grid",
|
||||
@@ -48,6 +48,23 @@ 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",
|
||||
@@ -105,16 +122,17 @@ 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: Array.from({ length: 6 }, () => []),
|
||||
device_consumption: Array.from({ length: 6 }, () => []),
|
||||
device_consumption_water: Array.from({ length: 2 }, () => []),
|
||||
device_consumption_gas: Array.from({ length: 2 }, () => []),
|
||||
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(() => []),
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
// 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": {
|
||||
@@ -34,6 +61,31 @@ 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",
|
||||
@@ -134,6 +186,29 @@ 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",
|
||||
|
||||
@@ -351,6 +351,26 @@ 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(
|
||||
@@ -375,15 +395,22 @@ 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];
|
||||
let cost = 0;
|
||||
statistics[id] = getStats(source).map((statistic) => {
|
||||
const change = statistic.change! * price;
|
||||
cost += change;
|
||||
return { ...statistic, change, state: cost, sum: cost };
|
||||
});
|
||||
statistics[id] = scaleStats(source, price);
|
||||
} else if (id in derivedBatteryFlows) {
|
||||
const { source, share } = derivedBatteryFlows[id];
|
||||
statistics[id] = scaleStats(source, share);
|
||||
} else if (id in statisticsFunctions) {
|
||||
statistics[id] = getStats(id);
|
||||
} else {
|
||||
|
||||
+16
-16
@@ -40,7 +40,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.1.0",
|
||||
"@babel/runtime": "8.1.2",
|
||||
"@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.3",
|
||||
"@tsparticles/preset-links": "4.5.3",
|
||||
"@versatiles/style": "9.0.0",
|
||||
"@tsparticles/engine": "4.5.4",
|
||||
"@tsparticles/preset-links": "4.5.4",
|
||||
"@versatiles/style": "9.1.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.17.0",
|
||||
"marked": "18.1.3",
|
||||
"maplibre-gl": "6.18.0",
|
||||
"marked": "18.2.0",
|
||||
"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.0",
|
||||
"@babel/core": "8.1.2",
|
||||
"@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.0.6",
|
||||
"@babel/plugin-transform-runtime": "8.1.2",
|
||||
"@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.1",
|
||||
"@rspack/core": "2.3.2",
|
||||
"@rspack/dev-server": "2.2.2",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "7.0.1",
|
||||
@@ -188,7 +188,7 @@
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.4.1",
|
||||
"generate-license-file": "4.2.5",
|
||||
"glob": "14.0.4",
|
||||
"glob": "14.0.6",
|
||||
"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.9.10",
|
||||
"prettier": "3.10.1",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.7",
|
||||
"sinon": "22.1.0",
|
||||
"tar": "7.5.22",
|
||||
"tar": "7.5.23",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.72.1",
|
||||
"vite": "8.3.7",
|
||||
"typescript-eslint": "8.72.2",
|
||||
"vite": "8.3.8",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.1.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "pnpm@12.18.0",
|
||||
"packageManager": "pnpm@12.20.0",
|
||||
"volta": {
|
||||
"node": "24.22.2"
|
||||
"node": "24.22.3"
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@custom-elements-manifest/analyzer": "0.11.0",
|
||||
"@rslib/core": "1.0.6",
|
||||
"@rslib/core": "1.0.7",
|
||||
"lit": "3.3.3",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
|
||||
Generated
+854
-858
File diff suppressed because it is too large.
Load diff
@@ -89,10 +89,19 @@ export class LegendFitController {
|
||||
|
||||
private _fit() {
|
||||
const list = this._element;
|
||||
const rowWidth = list?.getBoundingClientRect().width;
|
||||
if (!list || !rowWidth) {
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
const style = getComputedStyle(list);
|
||||
const rowWidth = parseFloat(style.width);
|
||||
// Bounding rects include transforms, such as a dialog scaling in, which
|
||||
// never resize the list, so measure in layout pixels
|
||||
const scale = list.getBoundingClientRect().width / rowWidth;
|
||||
if (!rowWidth || !scale) {
|
||||
return;
|
||||
}
|
||||
const layoutWidth = (element: Element) =>
|
||||
element.getBoundingClientRect().width / scale;
|
||||
const children = Array.from(list.children);
|
||||
const more = children.find((child) =>
|
||||
child.classList.contains("legend-more")
|
||||
@@ -107,9 +116,7 @@ export class LegendFitController {
|
||||
}
|
||||
this._more = more;
|
||||
}
|
||||
const widths = children
|
||||
.filter((child) => child !== more)
|
||||
.map((child) => child.getBoundingClientRect().width);
|
||||
const widths = children.filter((child) => child !== more).map(layoutWidth);
|
||||
// Values resize the items on every state update. Fitting each item at its
|
||||
// widest since the last resize keeps the more button from flickering.
|
||||
if (rowWidth !== this._rowWidth || widths.length !== this._widths.length) {
|
||||
@@ -122,9 +129,9 @@ export class LegendFitController {
|
||||
}
|
||||
const fit = fitInRows(
|
||||
this._widths,
|
||||
more?.getBoundingClientRect().width ?? 0,
|
||||
more ? layoutWidth(more) : 0,
|
||||
rowWidth,
|
||||
parseFloat(getComputedStyle(list).columnGap) || 0,
|
||||
parseFloat(style.columnGap) || 0,
|
||||
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
|
||||
);
|
||||
if (fit !== this.fit) {
|
||||
|
||||
@@ -35,24 +35,27 @@ function computeTimelineStateColor(
|
||||
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
|
||||
// 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 (
|
||||
// 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 =
|
||||
(domain === "person" || domain === "device_tracker") &&
|
||||
!((FIXED_DOMAIN_STATES[domain] || []) as readonly string[]).includes(state)
|
||||
) {
|
||||
return computeCssValue(
|
||||
`--state-${domain}-${slugify(state, "_")}-color`,
|
||||
computedStyles
|
||||
);
|
||||
}
|
||||
|
||||
const properties = stateColorProperties(stateObj, state);
|
||||
!((FIXED_DOMAIN_STATES[domain] || []) as readonly string[]).includes(state);
|
||||
const properties = isZoneState
|
||||
? undefined
|
||||
: stateColorProperties(stateObj, state);
|
||||
|
||||
if (!properties) {
|
||||
return undefined;
|
||||
const stateKey = slugify(state, "_");
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
return computeCssValue(
|
||||
[
|
||||
...(deviceClass
|
||||
? [`--state-${domain}-${deviceClass}-${stateKey}-color`]
|
||||
: []),
|
||||
`--state-${domain}-${stateKey}-color`,
|
||||
],
|
||||
computedStyles
|
||||
);
|
||||
}
|
||||
|
||||
const rgb = computeCssValue(properties, computedStyles);
|
||||
|
||||
@@ -87,11 +87,12 @@ export class HaFilterCategories extends LitElement {
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeCategories();
|
||||
this._categories = [];
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.get("scope") !== undefined) {
|
||||
if (changedProps.has("scope") && this.scope !== this._categoriesScope) {
|
||||
this._categories = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ 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);
|
||||
|
||||
@@ -117,7 +121,7 @@ export class HaMarqueeText extends LitElement {
|
||||
reachedEnd = true;
|
||||
}
|
||||
}
|
||||
this._textSpan.style.transform = `translateX(${this._position}px)`;
|
||||
this._textSpan.style.transform = `translateX(${this._position * this._animationFactor}px)`;
|
||||
if (reachedEnd) {
|
||||
this._pauseTimeout = window.setTimeout(() => {
|
||||
this._pauseTimeout = undefined;
|
||||
|
||||
@@ -465,10 +465,13 @@ export class HaServiceControl extends LitElement {
|
||||
serviceData?.flatFields.some((field) => showOptionalToggle(field))
|
||||
);
|
||||
|
||||
const targetEntities = this._getTargetedEntities(
|
||||
serviceData?.target,
|
||||
this._value
|
||||
);
|
||||
// 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 domain = this._value?.action
|
||||
? computeDomain(this._value.action)
|
||||
|
||||
@@ -112,7 +112,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
initialValue: {
|
||||
options: [],
|
||||
showIndices: false,
|
||||
select: () => undefined,
|
||||
select: () => true,
|
||||
fixDuplicateIds: async () => undefined,
|
||||
updateTriggerId: () => false,
|
||||
},
|
||||
|
||||
@@ -123,7 +123,7 @@ export const setProtectedAlarmControlPanelMode = async (
|
||||
(mode === "disarmed" && stateObj.attributes.code_format)
|
||||
) {
|
||||
const entry = await getExtendedEntityRegistryEntry(
|
||||
hass,
|
||||
hass.callWS,
|
||||
stateObj.entity_id
|
||||
).catch(() => undefined);
|
||||
const defaultCode = entry?.options?.alarm_control_panel?.default_code;
|
||||
|
||||
+324
-42
@@ -98,7 +98,7 @@ export const emptyHomeEnergyPreference =
|
||||
});
|
||||
|
||||
export const emptyBatteryEnergyPreference =
|
||||
(): BatterySourceTypeEnergyPreference => ({
|
||||
(): BatteryTotalsSourceTypeEnergyPreference => ({
|
||||
type: "battery",
|
||||
stat_energy_from: "",
|
||||
stat_energy_to: "",
|
||||
@@ -190,16 +190,125 @@ export interface HomeSourceTypeEnergyPreference {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface BatterySourceTypeEnergyPreference {
|
||||
export interface BatteryTotalsSourceTypeEnergyPreference {
|
||||
type: "battery";
|
||||
stat_energy_from: string;
|
||||
stat_energy_to: string;
|
||||
stat_energy_from: string; // out of the battery
|
||||
stat_energy_to: string; // into the battery
|
||||
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";
|
||||
|
||||
@@ -392,6 +501,28 @@ 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
|
||||
@@ -552,8 +683,8 @@ export const getReferencedStatisticIds = (
|
||||
}
|
||||
|
||||
if (source.type === "battery") {
|
||||
statIDs.push(source.stat_energy_from);
|
||||
statIDs.push(source.stat_energy_to);
|
||||
const { charge, discharge } = getBatteryEnergyStatIds(source);
|
||||
statIDs.push(...discharge, ...charge);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1637,7 +1768,8 @@ const getEnergyGasUnit = (
|
||||
return [source.stat_energy_to];
|
||||
}
|
||||
if (source.type === "battery") {
|
||||
return [source.stat_energy_from, source.stat_energy_to];
|
||||
const { charge, discharge } = getBatteryEnergyStatIds(source);
|
||||
return [...discharge, ...charge];
|
||||
}
|
||||
if (source.type === "grid") {
|
||||
return [source.stat_energy_from, source.stat_energy_to].filter(
|
||||
@@ -1710,6 +1842,14 @@ 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;
|
||||
@@ -1717,6 +1857,12 @@ 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[];
|
||||
}
|
||||
@@ -1758,14 +1904,10 @@ const getSummedDataPartial = (
|
||||
data: EnergyData,
|
||||
compare?: boolean
|
||||
): EnergySumData => {
|
||||
const statIds: {
|
||||
to_grid?: string[];
|
||||
from_grid?: string[];
|
||||
solar?: string[];
|
||||
to_battery?: string[];
|
||||
from_battery?: string[];
|
||||
home?: string[];
|
||||
} = {};
|
||||
const statIds: Partial<
|
||||
Record<Exclude<keyof EnergySumData, "total" | "timestamps">, string[]>
|
||||
> = {};
|
||||
const batteries: BatterySourceTypeEnergyPreference[] = [];
|
||||
|
||||
for (const source of data.prefs.energy_sources) {
|
||||
if (source.type === "solar") {
|
||||
@@ -1783,12 +1925,14 @@ const getSummedDataPartial = (
|
||||
}
|
||||
|
||||
if (source.type === "battery") {
|
||||
batteries.push(source);
|
||||
const { charge, discharge } = getBatteryEnergyStatIds(source);
|
||||
if (statIds.to_battery) {
|
||||
statIds.to_battery.push(source.stat_energy_to);
|
||||
statIds.from_battery!.push(source.stat_energy_from);
|
||||
statIds.to_battery.push(...charge);
|
||||
statIds.from_battery!.push(...discharge);
|
||||
} else {
|
||||
statIds.to_battery = [source.stat_energy_to];
|
||||
statIds.from_battery = [source.stat_energy_from];
|
||||
statIds.to_battery = [...charge];
|
||||
statIds.from_battery = [...discharge];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1814,6 +1958,23 @@ 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]) => {
|
||||
@@ -1884,11 +2045,12 @@ 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 = data.to_battery;
|
||||
const fromBattery = data.from_battery;
|
||||
const toBattery = measured ? data.to_battery_totals : data.to_battery;
|
||||
const fromBattery = measured ? data.from_battery_totals : data.from_battery;
|
||||
const total = outData.total;
|
||||
|
||||
data.timestamps.forEach((t) => {
|
||||
@@ -1903,13 +2065,25 @@ 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),
|
||||
});
|
||||
} = 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
|
||||
);
|
||||
|
||||
const usedTotal = hasHome ? Math.max(homeUsed, 0) : used_total;
|
||||
outData.used_total[t] = usedTotal;
|
||||
@@ -1933,13 +2107,15 @@ const computeConsumptionDataPartial = (
|
||||
return outData;
|
||||
};
|
||||
|
||||
export const computeConsumptionSingle = (data: {
|
||||
interface ConsumptionInput {
|
||||
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;
|
||||
@@ -1948,7 +2124,12 @@ export const computeConsumptionSingle = (data: {
|
||||
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);
|
||||
@@ -1956,11 +2137,12 @@ export const computeConsumptionSingle = (data: {
|
||||
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;
|
||||
|
||||
@@ -2028,6 +2210,70 @@ export const computeConsumptionSingle = (data: {
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -2517,18 +2763,54 @@ 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[] = [];
|
||||
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);
|
||||
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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
@@ -2645,7 +2927,7 @@ export const downloadEnergyData = (
|
||||
|
||||
const hasSolar = !!solar_productions.length;
|
||||
const hasHome = !!home_consumptions.length;
|
||||
const hasBattery = !!battery_ins.length;
|
||||
const hasBattery = batteries.length > 0;
|
||||
const hasGridReturn = !!grid_productions.length;
|
||||
const hasGridSource = !!grid_consumptions.length;
|
||||
|
||||
|
||||
@@ -266,10 +266,10 @@ export const computeEntityRegistryName = (
|
||||
};
|
||||
|
||||
export const getExtendedEntityRegistryEntry = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
callWS: HomeAssistant["callWS"],
|
||||
entityId: string
|
||||
): Promise<ExtEntityRegistryEntry> =>
|
||||
hass.callWS({
|
||||
callWS({
|
||||
type: "config/entity_registry/get",
|
||||
entity_id: entityId,
|
||||
});
|
||||
|
||||
@@ -60,3 +60,18 @@ export const validateHassioSession = async (
|
||||
): Promise<void> => {
|
||||
await hass.callWS({ type: "supervisor/ingress/validate_session", session });
|
||||
};
|
||||
|
||||
// Extends the session, or replaces it with a new one for the app if it has
|
||||
// expired. Returns the session to keep using.
|
||||
export const refreshHassioSession = async (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
slug: string,
|
||||
session: string
|
||||
): Promise<string> => {
|
||||
try {
|
||||
await validateHassioSession(hass, session);
|
||||
return session;
|
||||
} catch (_err: any) {
|
||||
return createHassioSession(hass, slug);
|
||||
}
|
||||
};
|
||||
+5
-5
@@ -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 type { ExtEntityRegistryEntry } from "./entity/entity_registry";
|
||||
import { getExtendedEntityRegistryEntry } 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 callWS<ExtEntityRegistryEntry>({
|
||||
type: "config/entity_registry/get",
|
||||
entity_id: stateObj.entity_id,
|
||||
}).catch(() => undefined);
|
||||
const lockRegistryEntry = await getExtendedEntityRegistryEntry(
|
||||
callWS,
|
||||
stateObj.entity_id
|
||||
).catch(() => undefined);
|
||||
const defaultCode = lockRegistryEntry?.options?.lock?.default_code;
|
||||
|
||||
if (stateObj!.attributes.code_format && !defaultCode) {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
registriesContext,
|
||||
uiContext,
|
||||
} from "./context";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantRegistries } from "../types";
|
||||
|
||||
/**
|
||||
* Reactive controller that keeps `NavigationPathInfo` in sync with a
|
||||
@@ -45,19 +45,8 @@ export class NavigationPathInfoController extends ContextController {
|
||||
})
|
||||
private _panels?: HomeAssistant["panels"];
|
||||
|
||||
@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: registriesContext, subscribe: true })
|
||||
private _registries?: HomeAssistantRegistries | null;
|
||||
|
||||
@consumeContext({
|
||||
context: configContext,
|
||||
@@ -79,6 +68,8 @@ export class NavigationPathInfoController extends ContextController {
|
||||
|
||||
private _path?: string;
|
||||
|
||||
private _resolvedInputs?: unknown[];
|
||||
|
||||
get info(): NavigationPathInfo {
|
||||
return this._info;
|
||||
}
|
||||
@@ -97,13 +88,31 @@ 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) {
|
||||
@@ -116,8 +125,8 @@ export class NavigationPathInfoController extends ContextController {
|
||||
if (
|
||||
!this._localize ||
|
||||
!this._panels ||
|
||||
!this._areas ||
|
||||
!this._devices ||
|
||||
!this._registries?.areas ||
|
||||
!this._registries.devices ||
|
||||
!this._config ||
|
||||
!this._connection
|
||||
) {
|
||||
@@ -128,8 +137,8 @@ export class NavigationPathInfoController extends ContextController {
|
||||
{
|
||||
localize: this._localize,
|
||||
panels: this._panels,
|
||||
areas: this._areas,
|
||||
devices: this._devices,
|
||||
areas: this._registries.areas,
|
||||
devices: this._registries.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, entityId);
|
||||
const regEntry = await getExtendedEntityRegistryEntry(api.callWS, entityId);
|
||||
if (!regEntry.config_entry_id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
this.hass.callWS,
|
||||
this.params.entityId
|
||||
);
|
||||
const areaMapping = entry?.options?.vacuum?.area_mapping;
|
||||
|
||||
@@ -56,7 +56,10 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
|
||||
try {
|
||||
const entry: ExtEntityRegistryEntry =
|
||||
await getExtendedEntityRegistryEntry(this.hass, this.params.entityId);
|
||||
await getExtendedEntityRegistryEntry(
|
||||
this.hass.callWS,
|
||||
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,
|
||||
this.hass.callWS,
|
||||
this.params.entityId
|
||||
);
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
try {
|
||||
this._entry = await getExtendedEntityRegistryEntry(
|
||||
this.hass,
|
||||
this.hass.callWS,
|
||||
this._entityId
|
||||
);
|
||||
} catch (_e) {
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { IngressAppInfo } from "../../data/hassio/ingress";
|
||||
import {
|
||||
createHassioSession,
|
||||
fetchIngressAppInfo,
|
||||
validateHassioSession,
|
||||
refreshHassioSession,
|
||||
} from "../../data/hassio/ingress";
|
||||
import {
|
||||
showAlertDialog,
|
||||
@@ -60,6 +60,10 @@ class HaPanelApp extends LitElement {
|
||||
|
||||
private _sessionKeepAlive?: number;
|
||||
|
||||
private _session?: { slug: string; session: string };
|
||||
|
||||
private _sessionRefresh?: Promise<void>;
|
||||
|
||||
private _fetchDataTimeout?: number;
|
||||
|
||||
private _autoRetryUntil?: number;
|
||||
@@ -92,17 +96,22 @@ class HaPanelApp extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("message", this._handleIframeMessage);
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
document.addEventListener("visibilitychange", this._handleVisibility);
|
||||
document.addEventListener("resume", this._handleVisibility);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("message", this._handleIframeMessage);
|
||||
window.removeEventListener("resize", this._handleResize);
|
||||
document.removeEventListener("visibilitychange", this._handleVisibility);
|
||||
document.removeEventListener("resume", this._handleVisibility);
|
||||
|
||||
if (this._sessionKeepAlive) {
|
||||
clearInterval(this._sessionKeepAlive);
|
||||
this._sessionKeepAlive = undefined;
|
||||
}
|
||||
this._session = undefined;
|
||||
if (this._fetchDataTimeout) {
|
||||
clearTimeout(this._fetchDataTimeout);
|
||||
this._fetchDataTimeout = undefined;
|
||||
@@ -342,17 +351,51 @@ class HaPanelApp extends LitElement {
|
||||
if (this._sessionKeepAlive) {
|
||||
clearInterval(this._sessionKeepAlive);
|
||||
}
|
||||
this._sessionKeepAlive = window.setInterval(async () => {
|
||||
try {
|
||||
await validateHassioSession(this.hass, session);
|
||||
} catch (_err: any) {
|
||||
session = await createHassioSession(this.hass, addonSlug);
|
||||
}
|
||||
}, 60000);
|
||||
this._session = { slug: addonSlug, session };
|
||||
this._sessionKeepAlive = window.setInterval(this._refreshSession, 60000);
|
||||
|
||||
this._addon = addon;
|
||||
}
|
||||
|
||||
// The keep-alive timer does not run while the device sleeps, so the session
|
||||
// may have expired by the time the page is visible again. Refresh it right
|
||||
// away instead of leaving the app unreachable until the next tick.
|
||||
// "resume" only fires in Chromium after a frozen page, other browsers rely
|
||||
// on "visibilitychange".
|
||||
private _handleVisibility = () => {
|
||||
if (!document.hidden) {
|
||||
this._refreshSession();
|
||||
}
|
||||
};
|
||||
|
||||
private _refreshSession = (): Promise<void> => {
|
||||
const current = this._session;
|
||||
if (!current) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this._sessionRefresh) {
|
||||
return this._sessionRefresh;
|
||||
}
|
||||
this._sessionRefresh = refreshHassioSession(
|
||||
this.hass,
|
||||
current.slug,
|
||||
current.session
|
||||
)
|
||||
.then((session) => {
|
||||
// the panel may have switched to another app meanwhile
|
||||
if (this._session === current) {
|
||||
this._session = { slug: current.slug, session };
|
||||
}
|
||||
})
|
||||
.catch((_err: any) => {
|
||||
// Connection to Home Assistant is gone, the next refresh will retry
|
||||
})
|
||||
.finally(() => {
|
||||
this._sessionRefresh = undefined;
|
||||
});
|
||||
return this._sessionRefresh;
|
||||
};
|
||||
|
||||
private async _checkLoaded(ev: Event): Promise<void> {
|
||||
const iframe = ev.target as HTMLIFrameElement;
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import { consume } from "../../../../../common/decorators/consume";
|
||||
import { ensureArray } from "../../../../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
|
||||
import "../../../../../components/animation/ha-fade-in";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import "../../../../../components/ha-trigger-icon";
|
||||
import "../../../../../components/item/ha-list-item-option";
|
||||
@@ -17,6 +19,7 @@ import type { TriggerCondition } from "../../../../../data/automation";
|
||||
import {
|
||||
automationTriggerContext,
|
||||
hasCustomTriggerId,
|
||||
triggerOptionsResolved,
|
||||
type AutomationTriggerContext,
|
||||
type TriggerIdOption,
|
||||
} from "../../trigger/automation-trigger-id";
|
||||
@@ -53,16 +56,33 @@ export class HaTriggerCondition extends LitElement {
|
||||
const selectedIds = (ensureArray(this.condition.id) ?? []).filter(Boolean);
|
||||
const triggerIdOptions = this._triggers?.options ?? [];
|
||||
const missingIds = this._missingIds(this.condition.id, triggerIdOptions);
|
||||
const status = this._triggers?.blueprintStatus;
|
||||
const resolved = triggerOptionsResolved(this._triggers);
|
||||
|
||||
if (!triggerIdOptions.length && !selectedIds.length) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
|
||||
);
|
||||
switch (status) {
|
||||
case "loading":
|
||||
return html`<ha-fade-in .delay=${500}>
|
||||
<ha-spinner size="small"></ha-spinner>
|
||||
</ha-fade-in>`;
|
||||
case "failed":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.blueprint_triggers_unavailable"
|
||||
);
|
||||
case "loaded":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.blueprint_no_trigger_ids"
|
||||
);
|
||||
case undefined:
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
${
|
||||
missingIds.length
|
||||
resolved && missingIds.length
|
||||
? html`<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_triggers"
|
||||
@@ -106,7 +126,13 @@ export class HaTriggerCondition extends LitElement {
|
||||
appearance="checkbox"
|
||||
>
|
||||
<span slot="start" class="trigger-row-leading">
|
||||
<span class="trigger-index-badge">${option.index + 1}</span>
|
||||
${
|
||||
this._triggers?.showIndices
|
||||
? html`<span class="trigger-index-badge"
|
||||
>${option.index + 1}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
<ha-trigger-icon
|
||||
.trigger=${
|
||||
"trigger" in option.trigger ? option.trigger.trigger : ""
|
||||
@@ -119,7 +145,9 @@ export class HaTriggerCondition extends LitElement {
|
||||
)}
|
||||
</span>
|
||||
${
|
||||
option.duplicate || hasCustomTriggerId(option)
|
||||
option.duplicate ||
|
||||
hasCustomTriggerId(option) ||
|
||||
!this._triggers?.showIndices
|
||||
? html`<span
|
||||
slot="supporting-text"
|
||||
class="trigger-id ${option.duplicate ? "duplicate" : ""}"
|
||||
@@ -152,14 +180,22 @@ export class HaTriggerCondition extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
appearance="checkbox"
|
||||
>
|
||||
<span slot="start" class="missing-trigger-badge">
|
||||
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
|
||||
<span class="missing-trigger-id">${id}</span>
|
||||
</span>
|
||||
${
|
||||
resolved
|
||||
? html`<span slot="start" class="missing-trigger-badge">
|
||||
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
|
||||
<span class="missing-trigger-id">${id}</span>
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
|
||||
)}
|
||||
${
|
||||
resolved
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
|
||||
)
|
||||
: id
|
||||
}
|
||||
</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
@@ -197,8 +233,7 @@ export class HaTriggerCondition extends LitElement {
|
||||
? ids
|
||||
: [...ids, id]
|
||||
: ids.filter((_id) => _id !== id);
|
||||
if (this._triggers) {
|
||||
this._triggers.select(this.condition, selectedIds);
|
||||
if (this._triggers?.select(this.condition, selectedIds)) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
|
||||
@@ -146,6 +146,15 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
this._manualEditor?.resetPastedConfig();
|
||||
this._updateConfig(config);
|
||||
},
|
||||
fetchBlueprintTriggers: async ({ path, input }) => {
|
||||
const result = await substituteBlueprint(
|
||||
this.hass,
|
||||
"automation",
|
||||
path,
|
||||
input || {}
|
||||
);
|
||||
return normalizeAutomationConfig(result.substituted_config).triggers;
|
||||
},
|
||||
});
|
||||
|
||||
private _newAutomationId?: string;
|
||||
|
||||
@@ -3,11 +3,14 @@ import type { LitElement, ReactiveController } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type {
|
||||
AutomationConfig,
|
||||
BlueprintAutomationConfig,
|
||||
SidebarConfig,
|
||||
Trigger,
|
||||
TriggerCondition,
|
||||
} from "../../../../data/automation";
|
||||
import { ContextSubscriptionController } from "../../../../common/decorators/consume";
|
||||
import { debounce } from "../../../../common/util/debounce";
|
||||
import { deepEqual } from "../../../../common/util/deep-equal";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
@@ -16,12 +19,15 @@ import {
|
||||
automationTriggerContext,
|
||||
cleanupRemovedGeneratedTriggerReferences,
|
||||
cleanupUnusedGeneratedTriggerIds,
|
||||
getBlueprintTriggerIdOptions,
|
||||
getTriggerIdOptions,
|
||||
getExplicitTriggerIds,
|
||||
isGeneratedTriggerId,
|
||||
makeDuplicateTriggerIdsUnique,
|
||||
replaceLeafTrigger,
|
||||
updateTriggerCondition,
|
||||
type TriggerIdOption,
|
||||
type BlueprintTriggersStatus,
|
||||
} from "./automation-trigger-id";
|
||||
import { preserveAutomationRowKeys } from "../ha-automation-sortable-list-mixin";
|
||||
|
||||
@@ -31,6 +37,10 @@ interface AutomationTriggerControllerOptions {
|
||||
canEdit: () => boolean;
|
||||
/** Apply one complete change through the editor's undo and dirty-state handling. */
|
||||
commit: (config: AutomationConfig) => void;
|
||||
/** Resolve a blueprint automation's triggers, with its inputs substituted. */
|
||||
fetchBlueprintTriggers: (
|
||||
useBlueprint: BlueprintAutomationConfig["use_blueprint"]
|
||||
) => Promise<Trigger | Trigger[] | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +53,9 @@ interface AutomationTriggerControllerOptions {
|
||||
*
|
||||
* The transformation helpers only change configuration. This controller preserves
|
||||
* the corresponding row keys so an open sidebar continues to track the edited row.
|
||||
*
|
||||
* A blueprint automation has no triggers of its own: options come from the
|
||||
* blueprint's substituted triggers, and conditions in its inputs change themselves.
|
||||
*/
|
||||
export class AutomationTriggerController implements ReactiveController {
|
||||
// Only read in event handlers, so it does not need to rerender the host.
|
||||
@@ -54,6 +67,18 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
// changes, so rendering and selection use the same IDs across host updates.
|
||||
private _getTriggerIdOptions = memoizeOne(getTriggerIdOptions);
|
||||
|
||||
private _getBlueprintTriggerIdOptions = memoizeOne(
|
||||
getBlueprintTriggerIdOptions
|
||||
);
|
||||
|
||||
/** The blueprint use whose triggers were last requested. */
|
||||
private _blueprintRequested?: BlueprintAutomationConfig["use_blueprint"];
|
||||
|
||||
private _blueprintTriggers?: Trigger | Trigger[];
|
||||
|
||||
/** "failed" while required inputs are empty. */
|
||||
private _blueprintStatus: BlueprintTriggersStatus = "loading";
|
||||
|
||||
constructor(
|
||||
private _host: LitElement,
|
||||
private _options: AutomationTriggerControllerOptions
|
||||
@@ -78,15 +103,39 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
_host.addController(this);
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
this._fetchBlueprintTriggers.cancel();
|
||||
this._blueprintRequested = undefined;
|
||||
}
|
||||
|
||||
/** Publish options before descendants render, including after undo or redo. */
|
||||
public hostUpdate() {
|
||||
const config = this._options.getConfig();
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
const options = this._getTriggerIdOptions(config.triggers);
|
||||
if (options !== this._provider.value.options) {
|
||||
this._provider.setValue({ ...this._provider.value, options });
|
||||
let options: TriggerIdOption[];
|
||||
let blueprintStatus: BlueprintTriggersStatus | undefined;
|
||||
let showIndices = this._provider.value.showIndices;
|
||||
if ("use_blueprint" in config && config.use_blueprint) {
|
||||
this._requestBlueprintTriggers(config.use_blueprint);
|
||||
options = this._getBlueprintTriggerIdOptions(this._blueprintTriggers);
|
||||
blueprintStatus = this._blueprintStatus;
|
||||
showIndices = false;
|
||||
} else {
|
||||
options = this._getTriggerIdOptions(config.triggers);
|
||||
}
|
||||
if (
|
||||
options !== this._provider.value.options ||
|
||||
blueprintStatus !== this._provider.value.blueprintStatus ||
|
||||
showIndices !== this._provider.value.showIndices
|
||||
) {
|
||||
this._provider.setValue({
|
||||
...this._provider.value,
|
||||
options,
|
||||
blueprintStatus,
|
||||
showIndices,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,11 +187,55 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private _selectTriggerIds = (condition: TriggerCondition, ids: string[]) => {
|
||||
const config = this._options.getConfig();
|
||||
if (!config || !this._options.canEdit()) {
|
||||
/** Request the blueprint's triggers again whenever its path or inputs change. */
|
||||
private _requestBlueprintTriggers(
|
||||
useBlueprint: BlueprintAutomationConfig["use_blueprint"]
|
||||
) {
|
||||
if (deepEqual(useBlueprint, this._blueprintRequested)) {
|
||||
return;
|
||||
}
|
||||
if (useBlueprint.path !== this._blueprintRequested?.path) {
|
||||
this._blueprintTriggers = undefined;
|
||||
}
|
||||
this._blueprintStatus = "loading";
|
||||
this._blueprintRequested = useBlueprint;
|
||||
this._fetchBlueprintTriggers(useBlueprint);
|
||||
}
|
||||
|
||||
private _fetchBlueprintTriggers = debounce(
|
||||
async (useBlueprint: BlueprintAutomationConfig["use_blueprint"]) => {
|
||||
let triggers: Trigger | Trigger[] | undefined;
|
||||
try {
|
||||
triggers = await this._options.fetchBlueprintTriggers(useBlueprint);
|
||||
} catch (_err) {
|
||||
if (useBlueprint === this._blueprintRequested) {
|
||||
this._blueprintStatus = "failed";
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (useBlueprint !== this._blueprintRequested) {
|
||||
return;
|
||||
}
|
||||
this._blueprintTriggers = triggers;
|
||||
this._blueprintStatus = "loaded";
|
||||
this._host.requestUpdate();
|
||||
},
|
||||
500,
|
||||
true
|
||||
);
|
||||
|
||||
private _selectTriggerIds = (
|
||||
condition: TriggerCondition,
|
||||
ids: string[]
|
||||
): boolean => {
|
||||
const config = this._options.getConfig();
|
||||
if (config && "use_blueprint" in config) {
|
||||
return false;
|
||||
}
|
||||
if (!config || !this._options.canEdit()) {
|
||||
return true;
|
||||
}
|
||||
const triggers = assignGeneratedTriggerIds(
|
||||
config.triggers,
|
||||
this._getTriggerIdOptions(config.triggers),
|
||||
@@ -157,6 +250,7 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
// Generated IDs that no condition or action references are no longer in use.
|
||||
// Strip them so the YAML does not accumulate stale identifiers.
|
||||
this._commit(cleanupUnusedGeneratedTriggerIds(updated));
|
||||
return true;
|
||||
};
|
||||
|
||||
private _fixDuplicateTriggerIds = async () => {
|
||||
|
||||
@@ -38,13 +38,28 @@ export interface TriggerIdOption {
|
||||
duplicate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a blueprint's substituted triggers are known. While "loading" or
|
||||
* "failed", options keep the last triggers fetched, if any.
|
||||
*/
|
||||
export type BlueprintTriggersStatus = "loaded" | "loading" | "failed";
|
||||
|
||||
/** Controller-provided options and operations for editors, sidebars, and row summaries. */
|
||||
export interface AutomationTriggerContext {
|
||||
options: TriggerIdOption[];
|
||||
/**
|
||||
* Left out for an automation with its own triggers. Set for a blueprint
|
||||
* automation, whose missing IDs can only be flagged once "loaded".
|
||||
*/
|
||||
blueprintStatus?: BlueprintTriggersStatus;
|
||||
/** True while the user is editing a `triggered_by` condition; trigger rows show their index badges only in this state. */
|
||||
showIndices: boolean;
|
||||
/** Pass the original condition object so identical conditions remain distinguishable. */
|
||||
select: (condition: TriggerCondition, ids: string[]) => void;
|
||||
/**
|
||||
* Pass the original condition object so identical conditions remain distinguishable.
|
||||
* Return false when the condition is not one of the automation's conditions, as in
|
||||
* a blueprint automation, whose conditions live in its inputs.
|
||||
*/
|
||||
select: (condition: TriggerCondition, ids: string[]) => boolean;
|
||||
/** Requests confirmation before migrating duplicate IDs throughout the automation. */
|
||||
fixDuplicateIds: () => Promise<void>;
|
||||
/**
|
||||
@@ -54,6 +69,13 @@ export interface AutomationTriggerContext {
|
||||
updateTriggerId: (original: Trigger, updated: Trigger) => boolean;
|
||||
}
|
||||
|
||||
/** Whether options list every trigger, so an ID not among them is really missing. */
|
||||
export const triggerOptionsResolved = (
|
||||
context: AutomationTriggerContext | undefined
|
||||
): boolean =>
|
||||
context?.blueprintStatus === undefined ||
|
||||
context.blueprintStatus === "loaded";
|
||||
|
||||
export const automationTriggerContext =
|
||||
createContext<AutomationTriggerContext>("automationTriggers");
|
||||
|
||||
@@ -140,6 +162,27 @@ export const getTriggerIdOptions = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Build options from a blueprint's substituted triggers. The blueprint owns its
|
||||
* triggers, so only those with a stored ID can be referenced, and triggers sharing
|
||||
* an ID are one option: a condition on that ID matches any of them.
|
||||
*/
|
||||
export const getBlueprintTriggerIdOptions = (
|
||||
triggers: Trigger | Trigger[] | undefined
|
||||
): TriggerIdOption[] => {
|
||||
const options: TriggerIdOption[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
flattenTriggers(triggers).forEach((trigger, index) => {
|
||||
const id = getTriggerId(trigger);
|
||||
if (!id || seenIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
seenIds.add(id);
|
||||
options.push({ id, trigger, index, draft: false, duplicate: false });
|
||||
});
|
||||
return options;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform leaf triggers recursively, retaining list wrappers and scalar/array shape.
|
||||
* Copy only changed branches; callbacks must return replacements rather than mutate leaves.
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { EntityRegistryEntry } from "../../../../data/entity/entity_registr
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
automationTriggerContext,
|
||||
triggerOptionsResolved,
|
||||
type AutomationTriggerContext,
|
||||
} from "./automation-trigger-id";
|
||||
|
||||
@@ -41,6 +42,7 @@ export class HaAutomationTriggerReferences extends LitElement {
|
||||
const missingIds = selectedIds.filter(
|
||||
(id) => !options.some((option) => option.id === id)
|
||||
);
|
||||
const resolved = triggerOptionsResolved(this._triggers);
|
||||
|
||||
if (options.length && !selectedIds.length) {
|
||||
return html`<span class="trigger-reference no-selection">
|
||||
@@ -87,17 +89,21 @@ export class HaAutomationTriggerReferences extends LitElement {
|
||||
</span>
|
||||
`
|
||||
),
|
||||
missingIds.map(
|
||||
() => html`
|
||||
<span class="trigger-reference missing">
|
||||
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
`
|
||||
missingIds.map((id) =>
|
||||
resolved
|
||||
? html`
|
||||
<span class="trigger-reference missing">
|
||||
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
`
|
||||
: html`<span class="trigger-reference">
|
||||
<span class="trigger-reference-label">${id}</span>
|
||||
</span>`
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -16,7 +16,12 @@ import type {
|
||||
EnergyPreferencesValidation,
|
||||
EnergyValidationIssue,
|
||||
} from "../../../../data/energy";
|
||||
import { saveEnergyPreferences } from "../../../../data/energy";
|
||||
import {
|
||||
getBatteryEnergyStatIds,
|
||||
getBatteryName,
|
||||
getBatteryPrimaryStat,
|
||||
saveEnergyPreferences,
|
||||
} from "../../../../data/energy";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
import { getStatisticLabel } from "../../../../data/recorder";
|
||||
import {
|
||||
@@ -114,8 +119,9 @@ export class EnergyBatterySettings extends LitElement {
|
||||
? html`
|
||||
<div class="items">
|
||||
${batterySources.map((source) => {
|
||||
const toEntityState =
|
||||
this.hass.states[source.stat_energy_to];
|
||||
const chargeStat =
|
||||
getBatteryEnergyStatIds(source).charge[0];
|
||||
const toEntityState = this.hass.states[chargeStat];
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<ha-automation-row
|
||||
@@ -145,10 +151,8 @@ export class EnergyBatterySettings extends LitElement {
|
||||
>${getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source.stat_energy_to,
|
||||
this.statsMetadata?.[
|
||||
source.stat_energy_to
|
||||
]
|
||||
chargeStat,
|
||||
this.statsMetadata?.[chargeStat]
|
||||
)}</span
|
||||
>`
|
||||
}
|
||||
@@ -188,13 +192,19 @@ export class EnergyBatterySettings extends LitElement {
|
||||
}
|
||||
|
||||
private _sourceName(source: BatterySourceTypeEnergyPreference) {
|
||||
const primary = getBatteryPrimaryStat(source);
|
||||
return (
|
||||
source.name ||
|
||||
getBatteryName(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source,
|
||||
this.statsMetadata
|
||||
) ||
|
||||
getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source.stat_energy_from,
|
||||
this.statsMetadata?.[source.stat_energy_from]
|
||||
primary,
|
||||
this.statsMetadata?.[primary]
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -215,7 +225,7 @@ export class EnergyBatterySettings extends LitElement {
|
||||
|
||||
private _editorHref(source?: BatterySourceTypeEnergyPreference) {
|
||||
return this.listPath
|
||||
? `${this.listPath}/battery/${source ? encodeURIComponent(source.stat_energy_from) : "new"}`
|
||||
? `${this.listPath}/battery/${source ? encodeURIComponent(getBatteryPrimaryStat(source)) : "new"}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -241,9 +251,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) && src.stat_energy_from === source.stat_energy_from
|
||||
(src) => isBatterySource(src) && getBatteryPrimaryStat(src) === key
|
||||
);
|
||||
}
|
||||
|
||||
@@ -255,7 +265,7 @@ export class EnergyBatterySettings extends LitElement {
|
||||
}
|
||||
const source = this.preferences.energy_sources.find(
|
||||
(src): src is BatterySourceTypeEnergyPreference =>
|
||||
isBatterySource(src) && src.stat_energy_from === key
|
||||
isBatterySource(src) && getBatteryPrimaryStat(src) === key
|
||||
);
|
||||
return (
|
||||
source &&
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { PowerConfig } from "../../../../data/energy";
|
||||
import type {
|
||||
BatteryDirectionsPowerConfig,
|
||||
PowerConfig,
|
||||
} from "../../../../data/energy";
|
||||
import { deepEqual } from "../../../../common/util/deep-equal";
|
||||
|
||||
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
|
||||
@@ -68,7 +71,10 @@ export function isPowerConfigValid(
|
||||
* Builds an exclude list for power statistics from existing sources.
|
||||
*/
|
||||
export function buildPowerExcludeList(
|
||||
sources: { stat_rate?: string; power_config?: PowerConfig }[],
|
||||
sources: {
|
||||
stat_rate?: string;
|
||||
power_config?: PowerConfig | BatteryDirectionsPowerConfig;
|
||||
}[],
|
||||
currentPowerConfig: PowerConfig,
|
||||
currentStatRate?: string
|
||||
): string[] {
|
||||
@@ -77,18 +83,9 @@ export function buildPowerExcludeList(
|
||||
sources.forEach((entry) => {
|
||||
if (entry.stat_rate) powerIds.push(entry.stat_rate);
|
||||
if (entry.power_config) {
|
||||
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);
|
||||
}
|
||||
powerIds.push(
|
||||
...Object.values(entry.power_config).filter((id): id is string => !!id)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -14,13 +14,15 @@ import "../../../../components/ha-alert";
|
||||
import "../../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import type {
|
||||
BatterySourceTypeEnergyPreference,
|
||||
BatteryTotalsSourceTypeEnergyPreference,
|
||||
PowerConfig,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
emptyBatteryEnergyPreference,
|
||||
energyStatisticHelpUrl,
|
||||
getBatteryEnergyStatIds,
|
||||
isBatteryDirections,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
getStatisticLabel,
|
||||
@@ -61,7 +63,7 @@ import type {
|
||||
import { EnergyEditorMixin, energyEditorStyles } from "./energy-editor";
|
||||
|
||||
interface BatteryFormState {
|
||||
source: BatterySourceTypeEnergyPreference;
|
||||
source: BatteryTotalsSourceTypeEnergyPreference;
|
||||
powerType: PowerType;
|
||||
powerConfig: PowerConfig;
|
||||
}
|
||||
@@ -121,7 +123,7 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
|
||||
})
|
||||
private _pending?: PowerIntegral[];
|
||||
|
||||
@state() private _source?: BatterySourceTypeEnergyPreference;
|
||||
@state() private _source?: BatteryTotalsSourceTypeEnergyPreference;
|
||||
|
||||
@state() private _powerType: PowerType = "none";
|
||||
|
||||
@@ -148,23 +150,23 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
|
||||
const params = this.params;
|
||||
this._error = undefined;
|
||||
this._saving = false;
|
||||
this._source = params.source
|
||||
? { ...params.source }
|
||||
const savedSource = this._savedSource;
|
||||
this._source = savedSource
|
||||
? { ...savedSource }
|
||||
: emptyBatteryEnergyPreference();
|
||||
|
||||
this._powerType = getPowerTypeFromConfig(
|
||||
params.source?.power_config,
|
||||
params.source?.stat_rate
|
||||
savedSource?.power_config,
|
||||
savedSource?.stat_rate
|
||||
);
|
||||
this._powerConfig = getInitialPowerConfig(
|
||||
params.source?.power_config,
|
||||
params.source?.stat_rate
|
||||
savedSource?.power_config,
|
||||
savedSource?.stat_rate
|
||||
);
|
||||
|
||||
const allSources: string[] = [];
|
||||
params.battery_sources.forEach((entry) => {
|
||||
allSources.push(entry.stat_energy_from);
|
||||
allSources.push(entry.stat_energy_to);
|
||||
const allSources = params.battery_sources.flatMap((entry) => {
|
||||
const { charge, discharge } = getBatteryEnergyStatIds(entry);
|
||||
return [...discharge, ...charge];
|
||||
});
|
||||
this._excludeList = allSources.filter(
|
||||
(id) =>
|
||||
@@ -175,7 +177,7 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
|
||||
this._excludeListPower = buildPowerExcludeList(
|
||||
params.battery_sources,
|
||||
this._powerConfig,
|
||||
params.source?.stat_rate
|
||||
savedSource?.stat_rate
|
||||
);
|
||||
|
||||
this._initDirtyTracking(
|
||||
@@ -189,6 +191,13 @@ 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")
|
||||
@@ -286,7 +295,7 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
|
||||
.powerConfig=${this._powerConfig}
|
||||
.excludeList=${this._excludeListPower}
|
||||
.helperEntityId=${getPowerHelperEntityId(
|
||||
this.params.source,
|
||||
this._savedSource,
|
||||
this._powerConfig
|
||||
)}
|
||||
.localizeBaseKey=${"ui.panel.config.energy.battery.dialog"}
|
||||
@@ -448,7 +457,7 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
|
||||
}
|
||||
}
|
||||
|
||||
const source: BatterySourceTypeEnergyPreference = {
|
||||
const source: BatteryTotalsSourceTypeEnergyPreference = {
|
||||
type: "battery",
|
||||
stat_energy_from: draft.stat_energy_from,
|
||||
stat_energy_to: draft.stat_energy_to,
|
||||
@@ -487,7 +496,7 @@ export class HaEnergyBatterySourceEditor extends EnergyEditorMixin(
|
||||
}
|
||||
|
||||
private _energyFromPower(
|
||||
draft: BatterySourceTypeEnergyPreference,
|
||||
draft: BatteryTotalsSourceTypeEnergyPreference,
|
||||
pending: PowerIntegral[]
|
||||
): Promise<string[] | undefined> {
|
||||
return confirmIntegrals(this, {
|
||||
|
||||
@@ -77,7 +77,10 @@ export class DialogVacuumSegmentMapping
|
||||
if (!this._params) return;
|
||||
|
||||
const entityId = this._params.entityId;
|
||||
this._entry = await getExtendedEntityRegistryEntry(this.hass, entityId);
|
||||
this._entry = await getExtendedEntityRegistryEntry(
|
||||
this.hass.callWS,
|
||||
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,
|
||||
this._api.callWS,
|
||||
`${domain}.${uniqueId}`
|
||||
).catch(() => undefined);
|
||||
if (entry && isCreated(entry)) {
|
||||
|
||||
@@ -225,12 +225,10 @@ class MqttSubscribeCard extends LitElement {
|
||||
|
||||
static styles = css`
|
||||
form {
|
||||
padding: var(--ha-space-4);
|
||||
padding-bottom: var(--ha-space-8);
|
||||
padding: var(--ha-space-4) 0 var(--ha-space-8);
|
||||
}
|
||||
.content_subscribe_panel {
|
||||
margin-top: var(--ha-space-6);
|
||||
max-width: 600px;
|
||||
max-width: var(--ha-content-width-narrow);
|
||||
margin: 0 auto;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
let entity: ExtEntityRegistryEntry | undefined;
|
||||
try {
|
||||
entity = await getExtendedEntityRegistryEntry(this.hass, id);
|
||||
entity = await getExtendedEntityRegistryEntry(this.hass.callWS, 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,
|
||||
this.hass.callWS,
|
||||
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,
|
||||
this.hass.callWS,
|
||||
this.entityId
|
||||
);
|
||||
fireEvent(this, "entity-entry-updated", entry);
|
||||
|
||||
@@ -192,7 +192,10 @@ export abstract class HuiNumericFavoriteCardFeatureBase<
|
||||
|
||||
private async _loadEntityEntry(entityId: string): Promise<void> {
|
||||
try {
|
||||
const entry = await getExtendedEntityRegistryEntry(this._api, entityId);
|
||||
const entry = await getExtendedEntityRegistryEntry(
|
||||
this._api.callWS,
|
||||
entityId
|
||||
);
|
||||
|
||||
if (this.context?.entity_id === entityId) {
|
||||
this._entry = entry;
|
||||
|
||||
@@ -18,11 +18,15 @@ import type {
|
||||
EnergyConsumptionData,
|
||||
EnergyData,
|
||||
EnergySourceByType,
|
||||
EnergySumData,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
computeEnergyDeviceLabels,
|
||||
energySourcesByType,
|
||||
getBatteryEnergyStatIds,
|
||||
getBatteryFlowStatIds,
|
||||
getBatteryName,
|
||||
getSummedData,
|
||||
validateEnergyCollectionKey,
|
||||
} from "../../../../data/energy";
|
||||
@@ -331,7 +335,7 @@ class HuiEnergySankeyCard
|
||||
typeNodes,
|
||||
typeLinks,
|
||||
consumption,
|
||||
summedData.timestamps,
|
||||
summedData,
|
||||
computedStyle
|
||||
);
|
||||
nodes.push(...separate.nodes);
|
||||
@@ -440,33 +444,47 @@ class HuiEnergySankeyCard
|
||||
typeNodes: Node[],
|
||||
typeLinks: Link[],
|
||||
consumption: EnergyConsumptionData,
|
||||
timestamps: number[],
|
||||
summedData: EnergySumData,
|
||||
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,
|
||||
statId: string,
|
||||
statIds: string[],
|
||||
colorVariable: string,
|
||||
idx: number,
|
||||
index: number,
|
||||
name: string | undefined
|
||||
name: string | undefined,
|
||||
measured?: SourceNode["measured"]
|
||||
): SourceNode => {
|
||||
const statId = statIds[0];
|
||||
const id = `${type}:${statId}`;
|
||||
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;
|
||||
});
|
||||
const { values, total } = sumSeries(statIds);
|
||||
nodes.push({
|
||||
id,
|
||||
label:
|
||||
@@ -487,10 +505,11 @@ class HuiEnergySankeyCard
|
||||
idx
|
||||
),
|
||||
index,
|
||||
entityId: statId in this._states ? statId : undefined,
|
||||
entityId:
|
||||
statIds.length === 1 && statId in this._states ? statId : undefined,
|
||||
group: type,
|
||||
});
|
||||
return { id, values };
|
||||
return { id, values, measured };
|
||||
};
|
||||
|
||||
const addGroup = <T>(
|
||||
@@ -505,41 +524,62 @@ class HuiEnergySankeyCard
|
||||
return sources.map(toNode);
|
||||
};
|
||||
|
||||
const batteryOut = addGroup("battery", split.battery, (source, idx) =>
|
||||
addNode(
|
||||
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(
|
||||
"battery",
|
||||
source.stat_energy_from,
|
||||
getBatteryEnergyStatIds(source).discharge,
|
||||
"--energy-battery-out-color",
|
||||
idx,
|
||||
0,
|
||||
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(
|
||||
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(
|
||||
"battery_in",
|
||||
source.stat_energy_to,
|
||||
getBatteryEnergyStatIds(source).charge,
|
||||
"--energy-battery-in-color",
|
||||
idx,
|
||||
1,
|
||||
source.name &&
|
||||
this._i18n.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_charged",
|
||||
{ name: source.name }
|
||||
)
|
||||
)
|
||||
);
|
||||
batteryName(source, "named_battery_charged"),
|
||||
flows && {
|
||||
grid: series(flows.grid_to_battery),
|
||||
solar: series(flows.solar_to_battery),
|
||||
}
|
||||
);
|
||||
});
|
||||
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,
|
||||
@@ -554,7 +594,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,
|
||||
@@ -567,7 +607,7 @@ class HuiEnergySankeyCard
|
||||
(source, idx) =>
|
||||
addNode(
|
||||
"grid_return",
|
||||
source.stat_energy_to!,
|
||||
[source.stat_energy_to!],
|
||||
"--energy-grid-return-color",
|
||||
idx,
|
||||
1,
|
||||
@@ -597,22 +637,57 @@ 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].map(({ id: nodeId, values }) => [
|
||||
nodeId,
|
||||
values ? (values[t] ?? 0) : 1,
|
||||
]);
|
||||
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);
|
||||
const links = typeLinks.flatMap((link) => {
|
||||
if (!isSplit(link.source) && !isSplit(link.target)) {
|
||||
return [link];
|
||||
}
|
||||
const flow = flows[`${link.source}>${link.target}`];
|
||||
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 splitLinks = new Map<string, Link & { value: number }>();
|
||||
for (const t of timestamps) {
|
||||
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]]
|
||||
)
|
||||
);
|
||||
addSplitSankeyLinks(
|
||||
splitLinks,
|
||||
flow[t] ?? 0,
|
||||
(flow[t] ?? 0) - Math.max(measuredFlow?.[t] ?? 0, 0),
|
||||
shares(link.source, t),
|
||||
shares(link.target, t)
|
||||
);
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import type { EnergyData, EnergySourceByType } from "../../../../data/energy";
|
||||
import {
|
||||
energySourcesByType,
|
||||
getBatteryEnergyStatIds,
|
||||
getBatteryName,
|
||||
validateEnergyCollectionKey,
|
||||
} from "../../../../data/energy";
|
||||
import { EnergyCollectionController } from "../../../../data/energy-collection-controller";
|
||||
@@ -182,7 +184,8 @@ export class HuiEnergySourcesTableCard
|
||||
compareCost: number | null,
|
||||
showCosts: boolean,
|
||||
compare: boolean,
|
||||
name?: string
|
||||
name?: string,
|
||||
moreInfo = true
|
||||
) {
|
||||
const displayPrecision =
|
||||
type === "gas" && this._gasUnits?.[statId] === energyUnit
|
||||
@@ -221,10 +224,10 @@ export class HuiEnergySourcesTableCard
|
||||
|
||||
return html`<tr
|
||||
class="mdc-data-table__row ${classMap({
|
||||
clickable: !isExternalStatistic(statId),
|
||||
clickable: moreInfo && !isExternalStatistic(statId),
|
||||
})}"
|
||||
@click=${this._handleMoreInfo}
|
||||
.entity=${statId}
|
||||
.entity=${moreInfo ? statId : undefined}
|
||||
>
|
||||
<td class="mdc-data-table__cell cell-bullet">
|
||||
<div
|
||||
@@ -562,6 +565,19 @@ 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 &&
|
||||
@@ -790,16 +806,17 @@ export class HuiEnergySourcesTableCard
|
||||
})}
|
||||
${_renderSimpleCategory("solar")}
|
||||
${types.battery?.map((source, idx) => {
|
||||
const { charge, discharge } = getBatteryEnergyStatIds(source);
|
||||
const {
|
||||
hasData: hasFromData,
|
||||
energy: energyFrom,
|
||||
energyCompare: energyFromCompare,
|
||||
} = _extractStatData(source.stat_energy_from, null);
|
||||
} = _sumStatData(discharge);
|
||||
const {
|
||||
hasData: hasToData,
|
||||
energy: energyTo,
|
||||
energyCompare: energyToCompare,
|
||||
} = _extractStatData(source.stat_energy_to, null);
|
||||
} = _sumStatData(charge);
|
||||
|
||||
if (!hasFromData && !hasToData) {
|
||||
return nothing;
|
||||
@@ -822,10 +839,16 @@ export class HuiEnergySourcesTableCard
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const name = getBatteryName(
|
||||
this._states,
|
||||
this._formatEntityName,
|
||||
source,
|
||||
this._data!.statsMetadata
|
||||
);
|
||||
return html` ${this._renderRow(
|
||||
computedStyles,
|
||||
"battery_out",
|
||||
source.stat_energy_from,
|
||||
discharge[0],
|
||||
idx,
|
||||
energyFrom,
|
||||
energyFromCompare,
|
||||
@@ -834,16 +857,17 @@ export class HuiEnergySourcesTableCard
|
||||
null,
|
||||
showCosts,
|
||||
compare,
|
||||
source.name
|
||||
name
|
||||
? this._i18n.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_discharged",
|
||||
{ name: source.name }
|
||||
{ name }
|
||||
)
|
||||
: ""
|
||||
: "",
|
||||
discharge.length === 1
|
||||
)}${this._renderRow(
|
||||
computedStyles,
|
||||
"battery_in",
|
||||
source.stat_energy_to,
|
||||
charge[0],
|
||||
idx,
|
||||
-energyTo,
|
||||
-energyToCompare,
|
||||
@@ -852,12 +876,13 @@ export class HuiEnergySourcesTableCard
|
||||
null,
|
||||
showCosts,
|
||||
compare,
|
||||
source.name
|
||||
name
|
||||
? this._i18n.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_charged",
|
||||
{ name: source.name }
|
||||
{ name }
|
||||
)
|
||||
: ""
|
||||
: "",
|
||||
charge.length === 1
|
||||
)}`;
|
||||
})}
|
||||
${renderCombinedRow(
|
||||
|
||||
@@ -32,6 +32,9 @@ import type {
|
||||
import {
|
||||
computeConsumptionData,
|
||||
energySourcesByType,
|
||||
getBatteryEnergyStatIds,
|
||||
getBatteryFlowStatIds,
|
||||
getBatteryName,
|
||||
getSuggestedPeriod,
|
||||
getSummedData,
|
||||
validateEnergyCollectionKey,
|
||||
@@ -72,6 +75,13 @@ 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,
|
||||
@@ -353,6 +363,11 @@ 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) {
|
||||
@@ -364,23 +379,40 @@ 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(source.stat_energy_to);
|
||||
statIds.from_battery!.push(source.stat_energy_from);
|
||||
statIds.to_battery.push(chargeKey);
|
||||
statIds.from_battery!.push(dischargeKey);
|
||||
} else {
|
||||
statIds.to_battery = [source.stat_energy_to];
|
||||
statIds.from_battery = [source.stat_energy_from];
|
||||
statIds.to_battery = [chargeKey];
|
||||
statIds.from_battery = [dischargeKey];
|
||||
}
|
||||
if (source.name) {
|
||||
statLabels.to_battery[source.stat_energy_to] = this._i18n.localize(
|
||||
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(
|
||||
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_charged",
|
||||
{ name: source.name }
|
||||
{ name }
|
||||
);
|
||||
statLabels.from_battery[dischargeKey] = this._i18n.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_discharged",
|
||||
{ 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;
|
||||
}
|
||||
@@ -518,6 +550,7 @@ export class HuiEnergyUsageGraphCard
|
||||
computedStyles,
|
||||
typeLabels,
|
||||
statLabels,
|
||||
batteryStatIds,
|
||||
trackY,
|
||||
combineTypes,
|
||||
true
|
||||
@@ -547,6 +580,7 @@ export class HuiEnergyUsageGraphCard
|
||||
computedStyles,
|
||||
typeLabels,
|
||||
statLabels,
|
||||
batteryStatIds,
|
||||
trackY,
|
||||
combineTypes,
|
||||
false
|
||||
@@ -638,6 +672,7 @@ export class HuiEnergyUsageGraphCard
|
||||
to_battery: Record<string, string>;
|
||||
from_battery: Record<string, string>;
|
||||
},
|
||||
batteryStatIds: BatteryStatIds,
|
||||
trackY: (v: number) => void,
|
||||
combineTypes: Set<string>,
|
||||
compare = false
|
||||
@@ -668,13 +703,24 @@ export class HuiEnergyUsageGraphCard
|
||||
statIds!.forEach((id) => {
|
||||
sets[id] = {};
|
||||
});
|
||||
sumEnergyStatistics(statistics, statIds!).forEach((stat) => {
|
||||
sumEnergyStatistics(
|
||||
statistics,
|
||||
batteryStatIds.combined[key] ?? 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] = {};
|
||||
@@ -705,30 +751,55 @@ 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> = {};
|
||||
for (const start of summedData.timestamps) {
|
||||
batteryNotUsed[start] =
|
||||
(summedData.from_battery?.[start] ?? 0) -
|
||||
consumptionData.used_battery[start];
|
||||
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];
|
||||
}
|
||||
}
|
||||
combinedData.used_battery = {
|
||||
used_battery:
|
||||
buildCombinedUsed(
|
||||
combinedData.from_battery,
|
||||
batteryNotUsed,
|
||||
consumptionData.used_battery
|
||||
) ?? {},
|
||||
used_battery: buildCombinedUsed(bySource, batteryNotUsed, used) ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
consumptionData.grid_to_battery,
|
||||
gridNotUsed,
|
||||
consumptionData.used_grid
|
||||
) ?? {},
|
||||
};
|
||||
|
||||
@@ -16,14 +16,21 @@ 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";
|
||||
@@ -76,8 +83,18 @@ 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)
|
||||
@@ -114,11 +131,22 @@ class HuiPowerSankeyCard
|
||||
const prefs = this._data?.prefs;
|
||||
if (states && prefs) {
|
||||
[
|
||||
...prefs.energy_sources.map((source) =>
|
||||
source.type === "gas" || source.type === "water"
|
||||
? undefined
|
||||
: source.stat_rate
|
||||
),
|
||||
...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.device_consumption.map((device) => device.stat_rate),
|
||||
].forEach((entityId) => {
|
||||
if (entityId && states[entityId]) {
|
||||
@@ -244,6 +272,7 @@ class HuiPowerSankeyCard
|
||||
typeLinks.push({
|
||||
source: "battery",
|
||||
target: "home",
|
||||
value: powerData.used_battery,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -263,12 +292,14 @@ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -289,6 +320,7 @@ class HuiPowerSankeyCard
|
||||
typeLinks.push({
|
||||
source: "grid",
|
||||
target: "home",
|
||||
value: powerData.used_grid,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,6 +338,7 @@ class HuiPowerSankeyCard
|
||||
typeLinks.push({
|
||||
source: "solar",
|
||||
target: "home",
|
||||
value: powerData.used_solar,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,12 +359,14 @@ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -356,6 +391,7 @@ class HuiPowerSankeyCard
|
||||
[homeNode, ...typeNodes].filter((node) => node.value > 0),
|
||||
typeLinks
|
||||
),
|
||||
powerData.measured,
|
||||
computedStyle
|
||||
);
|
||||
nodes.push(...separate.nodes);
|
||||
@@ -457,17 +493,25 @@ 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
|
||||
getName: (source: T) => string | undefined,
|
||||
getFlowIds?: (source: T) => string[] | undefined
|
||||
): SankeyFlowShare[] => {
|
||||
if (!sources) {
|
||||
nodes.push(...typeNodes.filter((node) => node.id === type));
|
||||
@@ -475,14 +519,18 @@ class HuiPowerSankeyCard
|
||||
}
|
||||
const shares: SankeyFlowShare[] = [];
|
||||
sources.forEach((source, idx) => {
|
||||
if (!source.stat_rate) {
|
||||
const ids = getFlowIds?.(source);
|
||||
const statId = ids ? ids[0] : source.stat_rate;
|
||||
if (!statId) {
|
||||
return;
|
||||
}
|
||||
const value = sign * this._getCurrentPower(source.stat_rate);
|
||||
const value = ids
|
||||
? ids.reduce((sum, id) => sum + flowPower(id), 0)
|
||||
: sign * this._getCurrentPower(statId);
|
||||
if (value <= 0) {
|
||||
return;
|
||||
}
|
||||
const id = `${type}:${source.stat_rate}`;
|
||||
const id = `${type}:${statId}`;
|
||||
nodes.push({
|
||||
id,
|
||||
label:
|
||||
@@ -490,8 +538,8 @@ class HuiPowerSankeyCard
|
||||
getStatisticLabel(
|
||||
this._states,
|
||||
this._formatEntityName,
|
||||
source.stat_rate,
|
||||
this._data!.statsMetadata[source.stat_rate]
|
||||
statId,
|
||||
this._data!.statsMetadata[statId]
|
||||
),
|
||||
value,
|
||||
color: getEnergyColor(
|
||||
@@ -505,21 +553,34 @@ class HuiPowerSankeyCard
|
||||
index,
|
||||
group: type,
|
||||
entityId:
|
||||
source.stat_rate in this._states ? source.stat_rate : undefined,
|
||||
(!ids || ids.length === 1) && statId in this._states
|
||||
? statId
|
||||
: undefined,
|
||||
});
|
||||
shares.push([id, value]);
|
||||
if (!ids) {
|
||||
shares.push([id, value]);
|
||||
}
|
||||
});
|
||||
return shares;
|
||||
};
|
||||
|
||||
const batteryName =
|
||||
(key: "named_battery_discharged" | "named_battery_charged") =>
|
||||
(source: { name?: string }) =>
|
||||
source.name &&
|
||||
this._i18n.localize(
|
||||
`ui.panel.lovelace.cards.energy.energy_sources_table.${key}`,
|
||||
{ name: source.name }
|
||||
(source: NonNullable<EnergySourceByType["battery"]>[number]) => {
|
||||
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 batteryOut = addNodes(
|
||||
"battery",
|
||||
@@ -527,7 +588,11 @@ class HuiPowerSankeyCard
|
||||
1,
|
||||
"--energy-battery-out-color",
|
||||
0,
|
||||
batteryName("named_battery_discharged")
|
||||
batteryName("named_battery_discharged"),
|
||||
(source) => {
|
||||
const rates = getBatteryFlowPowerIds(source);
|
||||
return rates && flowIds(rates.battery_to_home, rates.battery_to_grid);
|
||||
}
|
||||
);
|
||||
const batteryIn = addNodes(
|
||||
"battery_in",
|
||||
@@ -535,7 +600,11 @@ class HuiPowerSankeyCard
|
||||
-1,
|
||||
"--energy-battery-in-color",
|
||||
1,
|
||||
batteryName("named_battery_charged")
|
||||
batteryName("named_battery_charged"),
|
||||
(source) => {
|
||||
const rates = getBatteryFlowPowerIds(source);
|
||||
return rates && flowIds(rates.grid_to_battery, rates.solar_to_battery);
|
||||
}
|
||||
);
|
||||
const gridIn = addNodes(
|
||||
"grid",
|
||||
@@ -592,15 +661,54 @@ 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) =>
|
||||
typeLinks.forEach((link) => {
|
||||
const flow = MEASURED_LINKS[`${link.source}>${link.target}`];
|
||||
addSplitSankeyLinks(
|
||||
links,
|
||||
link.value,
|
||||
link.value - (split.battery && flow ? (measured?.[flow] ?? 0) : 0),
|
||||
sharesByNode[link.source],
|
||||
sharesByNode[link.target]
|
||||
)
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
return { nodes, links: [...links.values()] };
|
||||
}
|
||||
@@ -645,14 +753,32 @@ 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;
|
||||
prefs.energy_sources
|
||||
.filter((source) => source.type === "battery")
|
||||
.forEach((source) => {
|
||||
if (source.type === "battery" && source.stat_rate) {
|
||||
net_battery += this._getCurrentPower(source.stat_rate);
|
||||
}
|
||||
});
|
||||
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);
|
||||
}
|
||||
});
|
||||
if (net_battery > 0) {
|
||||
from_battery = net_battery;
|
||||
} else if (net_battery < 0) {
|
||||
@@ -665,75 +791,33 @@ class HuiPowerSankeyCard
|
||||
const homePower = homeStatRate
|
||||
? getPowerFromState(this._states[homeStatRate])
|
||||
: undefined;
|
||||
const used_total =
|
||||
homePower ?? from_grid + solar + from_battery - to_grid - to_battery;
|
||||
|
||||
// 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)
|
||||
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 }
|
||||
);
|
||||
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,
|
||||
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),
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
try {
|
||||
this._entry = await getExtendedEntityRegistryEntry(
|
||||
this._api,
|
||||
this._api.callWS,
|
||||
this._config!.entity
|
||||
);
|
||||
} catch (_e) {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
formatDateMonth,
|
||||
formatDateMonthShort,
|
||||
formatDateVeryShort,
|
||||
formatDateWeekdayVeryShortDate,
|
||||
formatDateYear,
|
||||
} from "../../../common/datetime/format_date";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
@@ -367,7 +368,7 @@ export class HuiEnergyPeriodSelector extends LitElement {
|
||||
this._hassConfig
|
||||
)}`
|
||||
: simpleRange === "day"
|
||||
? html`${formatDateVeryShort(
|
||||
? html`${formatDateWeekdayVeryShortDate(
|
||||
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, type PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
@@ -30,19 +30,9 @@ import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { computeDefaultSecurityAlertSeverity } from "../strategies/security-alerts";
|
||||
import {
|
||||
createSecurityEntityFilter,
|
||||
type SecurityEntityContext,
|
||||
} from "../security-entity-filter";
|
||||
import { createSecurityEntityFilter } 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 })
|
||||
@@ -60,24 +50,6 @@ 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}>
|
||||
@@ -107,10 +79,19 @@ export class SecurityAlertsEditor extends LitElement {
|
||||
index: number
|
||||
) {
|
||||
const stateObj = this._states[alertEntity.entity];
|
||||
const { primary, secondary } =
|
||||
stateObj && this._entityContext
|
||||
? computeEntityPickerDisplay(this._entityContext, stateObj)
|
||||
: { primary: alertEntity.entity, secondary: undefined };
|
||||
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 severity =
|
||||
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
|
||||
|
||||
@@ -158,7 +139,10 @@ export class SecurityAlertsEditor extends LitElement {
|
||||
fireEvent(this, "value-changed", { value: next });
|
||||
}
|
||||
|
||||
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
|
||||
private _entityFilter = createSecurityEntityFilter(
|
||||
() => this._states,
|
||||
() => this._registries
|
||||
);
|
||||
|
||||
private _getIndex(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import "../../../components/ha-button";
|
||||
@@ -19,10 +19,7 @@ import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
import "../../../components/entity/ha-favorites-editor";
|
||||
import "../components/security-alerts-editor";
|
||||
import {
|
||||
createSecurityEntityFilter,
|
||||
type SecurityEntityContext,
|
||||
} from "../security-entity-filter";
|
||||
import { createSecurityEntityFilter } from "../security-entity-filter";
|
||||
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
|
||||
|
||||
@customElement("dialog-edit-security")
|
||||
@@ -37,26 +34,12 @@ 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) {
|
||||
@@ -179,7 +162,10 @@ export class DialogEditSecurity extends DirtyStateProviderMixin<SecurityFrontend
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
|
||||
private _entityFilter = createSecurityEntityFilter(
|
||||
() => this._states,
|
||||
() => this._registries
|
||||
);
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this.params || !this._state) return;
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { registriesContext, statesContext } from "../../data/context";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistantRegistries } from "../../types";
|
||||
import { isSecurityPanelEntity } from "./strategies/security-view-strategy";
|
||||
|
||||
export type SecurityEntityContext = ContextType<typeof registriesContext> & {
|
||||
states: ContextType<typeof statesContext>;
|
||||
};
|
||||
|
||||
export const createSecurityEntityFilter =
|
||||
(getContext: () => SecurityEntityContext | undefined) =>
|
||||
(
|
||||
getStates: () => HassEntities | null | undefined,
|
||||
getRegistries: () => HomeAssistantRegistries | null | undefined
|
||||
) =>
|
||||
(entity: HassEntity): boolean => {
|
||||
const context = getContext();
|
||||
return context ? isSecurityPanelEntity(context, entity) : false;
|
||||
const states = getStates();
|
||||
const registries = getRegistries();
|
||||
return states && registries
|
||||
? isSecurityPanelEntity(
|
||||
states,
|
||||
registries.entities,
|
||||
registries.devices,
|
||||
registries.areas,
|
||||
registries.floors,
|
||||
entity
|
||||
)
|
||||
: false;
|
||||
};
|
||||
@@ -83,16 +83,20 @@ export const findSecurityEntities = (sources: EntityFilterSources): string[] =>
|
||||
findFilteredEntities(sources, securityEntityFilters);
|
||||
|
||||
export const isSecurityPanelEntity = (
|
||||
hass: EntityFilterSources,
|
||||
states: HomeAssistant["states"],
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
stateObj: HassEntity
|
||||
): boolean =>
|
||||
securityEntityFilters.some((filter) =>
|
||||
generateEntityFilter(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
states,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
filter
|
||||
)(stateObj.entity_id)
|
||||
);
|
||||
@@ -188,7 +192,14 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
const favoriteEntities = (config.favorite_entities ?? []).filter(
|
||||
(entityId) =>
|
||||
hass.states[entityId] &&
|
||||
isSecurityPanelEntity(hass, hass.states[entityId])
|
||||
isSecurityPanelEntity(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
hass.states[entityId]
|
||||
)
|
||||
);
|
||||
|
||||
if (favoriteEntities.length > 0) {
|
||||
|
||||
@@ -358,7 +358,7 @@ class PanelTodo extends LitElement {
|
||||
}
|
||||
|
||||
const entityRegistryEntry = await getExtendedEntityRegistryEntry(
|
||||
this.hass,
|
||||
this.hass.callWS,
|
||||
this._entityId
|
||||
);
|
||||
|
||||
|
||||
@@ -6442,6 +6442,8 @@
|
||||
"label": "Triggered by",
|
||||
"no_triggers": "There are no triggers in this automation. Add a trigger first.",
|
||||
"no_triggers_selected": "No triggers selected.",
|
||||
"blueprint_no_trigger_ids": "This blueprint has no triggers with IDs, so there is nothing to choose.",
|
||||
"blueprint_triggers_unavailable": "Complete all of the blueprint's required inputs to see its triggers.",
|
||||
"id": "Trigger",
|
||||
"missing_trigger": "Missing trigger",
|
||||
"missing_triggers": "This condition references a trigger that no longer exists. Uncheck it to clear it.",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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
@@ -40,6 +40,18 @@ 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();
|
||||
@@ -61,6 +73,36 @@ 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);
|
||||
@@ -107,6 +149,28 @@ 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({
|
||||
|
||||
+305
-3
@@ -49,7 +49,8 @@ const checkConsumptionResult = (
|
||||
to_battery: number | undefined;
|
||||
from_battery: number | undefined;
|
||||
},
|
||||
exact = true
|
||||
exact = true,
|
||||
options?: Parameters<typeof computeConsumptionSingle>[1]
|
||||
): {
|
||||
grid_to_battery: number;
|
||||
battery_to_grid: number;
|
||||
@@ -60,8 +61,9 @@ const checkConsumptionResult = (
|
||||
used_battery: number;
|
||||
used_total: number;
|
||||
} => {
|
||||
const result = computeConsumptionSingle(input);
|
||||
const result = computeConsumptionSingle(input, options);
|
||||
if (exact) {
|
||||
const measured = options?.measured;
|
||||
assert.equal(
|
||||
result.used_total,
|
||||
result.used_solar + result.used_battery + result.used_grid
|
||||
@@ -71,13 +73,25 @@ const checkConsumptionResult = (
|
||||
result.solar_to_grid + result.battery_to_grid
|
||||
);
|
||||
assert.equal(
|
||||
input.to_battery || 0,
|
||||
(input.to_battery || 0) +
|
||||
(measured?.grid_to_battery || 0) +
|
||||
(measured?.solar_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;
|
||||
};
|
||||
@@ -649,6 +663,294 @@ 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;
|
||||
|
||||
Vendored
+14
-1
@@ -10,6 +10,7 @@ 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";
|
||||
|
||||
@@ -17,6 +18,8 @@ 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;
|
||||
}
|
||||
@@ -59,6 +62,15 @@ 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",
|
||||
@@ -101,7 +113,8 @@ 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") {
|
||||
ids.push(source.stat_energy_from, source.stat_energy_to);
|
||||
const { charge, discharge } = getBatteryEnergyStatIds(source);
|
||||
ids.push(...discharge, ...charge);
|
||||
} else if (source.type === "home") {
|
||||
ids.push(source.stat_energy_to);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, assert, describe, it, vi } from "vitest";
|
||||
import { refreshHassioSession } from "../../src/data/hassio/ingress";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
interface IngressMessage {
|
||||
type: string;
|
||||
slug?: string;
|
||||
session?: string;
|
||||
}
|
||||
|
||||
describe("Refresh hassio session", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps the session when it is still valid", async () => {
|
||||
const calls: IngressMessage[] = [];
|
||||
const hass = {
|
||||
callWS: async (msg: IngressMessage) => {
|
||||
calls.push(msg);
|
||||
return {};
|
||||
},
|
||||
} as unknown as Pick<HomeAssistant, "callWS">;
|
||||
|
||||
const session = await refreshHassioSession(hass, "my_app", "old-session");
|
||||
|
||||
assert.strictEqual(session, "old-session");
|
||||
assert.deepEqual(calls, [
|
||||
{ type: "supervisor/ingress/validate_session", session: "old-session" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a new session for the app when validation fails", async () => {
|
||||
vi.stubGlobal("document", {});
|
||||
vi.stubGlobal("location", { protocol: "http:" });
|
||||
const calls: IngressMessage[] = [];
|
||||
const hass = {
|
||||
callWS: async (msg: IngressMessage) => {
|
||||
calls.push(msg);
|
||||
if (msg.type === "supervisor/ingress/validate_session") {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
return { session: "new-session" };
|
||||
},
|
||||
} as unknown as Pick<HomeAssistant, "callWS">;
|
||||
|
||||
const session = await refreshHassioSession(hass, "my_app", "old-session");
|
||||
|
||||
assert.strictEqual(session, "new-session");
|
||||
assert.deepEqual(calls, [
|
||||
{ type: "supervisor/ingress/validate_session", session: "old-session" },
|
||||
{ type: "supervisor/ingress/session", slug: "my_app" },
|
||||
]);
|
||||
assert.strictEqual(
|
||||
global.document.cookie,
|
||||
"ingress_session=new-session;path=/api/hassio_ingress/;SameSite=Strict"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when neither validation nor creation works", async () => {
|
||||
const hass = {
|
||||
callWS: async () => {
|
||||
throw new Error("Connection lost");
|
||||
},
|
||||
} as unknown as Pick<HomeAssistant, "callWS">;
|
||||
|
||||
const result = await refreshHassioSession(
|
||||
hass,
|
||||
"my_app",
|
||||
"old-session"
|
||||
).then(
|
||||
() => "resolved",
|
||||
() => "rejected"
|
||||
);
|
||||
|
||||
assert.strictEqual(result, "rejected");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { ContextEvent } from "@lit/context";
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AutomationConfig,
|
||||
BlueprintAutomationConfig,
|
||||
Trigger,
|
||||
} from "../../../../../src/data/automation";
|
||||
import { AutomationTriggerController } from "../../../../../src/panels/config/automation/trigger/automation-trigger-controller";
|
||||
import {
|
||||
automationTriggerContext,
|
||||
type AutomationTriggerContext,
|
||||
} from "../../../../../src/panels/config/automation/trigger/automation-trigger-id";
|
||||
|
||||
class TestHost extends LitElement {}
|
||||
customElements.define("test-automation-trigger-host", TestHost);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-automation-trigger-host": TestHost;
|
||||
}
|
||||
}
|
||||
|
||||
// A stored blueprint automation: only `use_blueprint`, with input values.
|
||||
const blueprint = (path: string, input: Record<string, unknown> = {}) =>
|
||||
({ use_blueprint: { path, input } }) as unknown as BlueprintAutomationConfig;
|
||||
|
||||
const trigger = (id: string): Trigger => ({
|
||||
trigger: "event",
|
||||
event_type: id,
|
||||
id,
|
||||
});
|
||||
|
||||
/** A fetch whose responses the test resolves or rejects in any order. */
|
||||
const createFetch = () => {
|
||||
const pending: {
|
||||
resolve: (triggers: Trigger[] | undefined) => void;
|
||||
reject: (err: Error) => void;
|
||||
}[] = [];
|
||||
const fetch = vi.fn(
|
||||
() =>
|
||||
new Promise<Trigger[] | undefined>((resolve, reject) => {
|
||||
pending.push({ resolve, reject });
|
||||
})
|
||||
);
|
||||
return { fetch, pending };
|
||||
};
|
||||
|
||||
describe("AutomationTriggerController with a blueprint automation", () => {
|
||||
let host: TestHost;
|
||||
let config: AutomationConfig;
|
||||
let fetch: ReturnType<typeof createFetch>;
|
||||
let controller: AutomationTriggerController;
|
||||
|
||||
// What a condition editor inside the automation receives.
|
||||
const context = (): AutomationTriggerContext => {
|
||||
let value: AutomationTriggerContext | undefined;
|
||||
const child = document.createElement("div");
|
||||
host.appendChild(child);
|
||||
child.dispatchEvent(
|
||||
new ContextEvent(automationTriggerContext, child, (v) => {
|
||||
value = v;
|
||||
})
|
||||
);
|
||||
child.remove();
|
||||
return value!;
|
||||
};
|
||||
|
||||
const ids = () => context().options.map((option) => option.id);
|
||||
|
||||
// The host's update after the editor's config changes.
|
||||
const update = (next: AutomationConfig) => {
|
||||
config = next;
|
||||
controller.hostUpdate();
|
||||
};
|
||||
|
||||
// Let timers and answered fetches run, then the update they request.
|
||||
const settle = async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
controller.hostUpdate();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Left out of the document, so only the test runs the host's updates.
|
||||
host = document.createElement("test-automation-trigger-host");
|
||||
fetch = createFetch();
|
||||
config = blueprint("motion.yaml");
|
||||
controller = new AutomationTriggerController(host, {
|
||||
getConfig: () => config,
|
||||
canEdit: () => true,
|
||||
commit: vi.fn(),
|
||||
fetchBlueprintTriggers: fetch.fetch,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller.hostDisconnected();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("offers the IDs of the blueprint's substituted triggers", async () => {
|
||||
update(blueprint("motion.yaml", { sensor: "binary_sensor.hall" }));
|
||||
expect(fetch.fetch).toHaveBeenCalledWith({
|
||||
path: "motion.yaml",
|
||||
input: { sensor: "binary_sensor.hall" },
|
||||
});
|
||||
fetch.pending[0].resolve([trigger("motion_on"), trigger("motion_off")]);
|
||||
await settle();
|
||||
|
||||
expect(ids()).toEqual(["motion_on", "motion_off"]);
|
||||
});
|
||||
|
||||
it("reports the options as loading until the triggers arrive", async () => {
|
||||
update(blueprint("motion.yaml"));
|
||||
expect(context().blueprintStatus).toBe("loading");
|
||||
|
||||
fetch.pending[0].resolve([trigger("motion_on")]);
|
||||
await settle();
|
||||
|
||||
expect(context().blueprintStatus).toBe("loaded");
|
||||
});
|
||||
|
||||
it("reports a blueprint whose triggers have no IDs as loaded", async () => {
|
||||
update(blueprint("motion.yaml"));
|
||||
fetch.pending[0].resolve([{ trigger: "event", event_type: "motion" }]);
|
||||
await settle();
|
||||
|
||||
expect(ids()).toEqual([]);
|
||||
expect(context().blueprintStatus).toBe("loaded");
|
||||
});
|
||||
|
||||
it("reports a blueprint that never substituted as failed", async () => {
|
||||
update(blueprint("motion.yaml"));
|
||||
fetch.pending[0].reject(new Error("Missing input sensor"));
|
||||
await settle();
|
||||
|
||||
expect(ids()).toEqual([]);
|
||||
expect(context().blueprintStatus).toBe("failed");
|
||||
});
|
||||
|
||||
it("keeps the last triggers listed, as loading, while new inputs load", async () => {
|
||||
update(blueprint("motion.yaml", { id: "motion_on" }));
|
||||
fetch.pending[0].resolve([trigger("motion_on")]);
|
||||
await settle();
|
||||
|
||||
update(blueprint("motion.yaml", { id: "motion_detected" }));
|
||||
|
||||
expect(ids()).toEqual(["motion_on"]);
|
||||
expect(context().blueprintStatus).toBe("loading");
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
fetch.pending[1].resolve([trigger("motion_detected")]);
|
||||
await settle();
|
||||
|
||||
expect(ids()).toEqual(["motion_detected"]);
|
||||
expect(context().blueprintStatus).toBe("loaded");
|
||||
});
|
||||
|
||||
it("fetches again only when `use_blueprint` changes by value", async () => {
|
||||
update(blueprint("motion.yaml", { wait: 1 }));
|
||||
// YAML mode parses a new, equal object when another key is edited.
|
||||
update({ ...blueprint("motion.yaml", { wait: 1 }), alias: "Hall" });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(fetch.fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses only the response for the latest inputs", async () => {
|
||||
update(blueprint("motion.yaml", { wait: 1 }));
|
||||
update(blueprint("motion.yaml", { wait: 2 }));
|
||||
await vi.runAllTimersAsync();
|
||||
expect(fetch.fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
fetch.pending[1].resolve([trigger("latest")]);
|
||||
fetch.pending[0].resolve([trigger("stale")]);
|
||||
await settle();
|
||||
|
||||
expect(ids()).toEqual(["latest"]);
|
||||
});
|
||||
|
||||
it("keeps the last triggers when the inputs fail to substitute", async () => {
|
||||
update(blueprint("motion.yaml", { wait: 1 }));
|
||||
fetch.pending[0].resolve([trigger("motion_on")]);
|
||||
await settle();
|
||||
|
||||
update(blueprint("motion.yaml", { wait: undefined }));
|
||||
await vi.runAllTimersAsync();
|
||||
fetch.pending[1].reject(new Error("Missing input wait"));
|
||||
await settle();
|
||||
|
||||
expect(ids()).toEqual(["motion_on"]);
|
||||
expect(context().blueprintStatus).toBe("failed");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
cleanupRemovedGeneratedTriggerReferences,
|
||||
cleanupUnusedGeneratedTriggerIds,
|
||||
GENERATED_TRIGGER_ID_PREFIX,
|
||||
getBlueprintTriggerIdOptions,
|
||||
getExplicitTriggerIds,
|
||||
getTriggerIdOptions,
|
||||
hasCustomTriggerId,
|
||||
@@ -25,6 +26,40 @@ const GENERATED_TRIGGER_ID_PATTERN = new RegExp(
|
||||
);
|
||||
|
||||
describe("automation trigger IDs", () => {
|
||||
it("offers a blueprint's stored trigger IDs once each, without generating any", () => {
|
||||
const motion: Trigger = {
|
||||
trigger: "state",
|
||||
entity_id: "binary_sensor.motion",
|
||||
id: "motion_on",
|
||||
};
|
||||
const triggers = [
|
||||
motion,
|
||||
{ trigger: "time_pattern", minutes: "/5" },
|
||||
{
|
||||
trigger: "state",
|
||||
entity_id: "binary_sensor.presence",
|
||||
id: "motion_on",
|
||||
},
|
||||
{ triggers: [{ trigger: "sun", event: "sunset", id: "sunset" }] },
|
||||
] as Trigger[];
|
||||
|
||||
const options = getBlueprintTriggerIdOptions(triggers);
|
||||
|
||||
expect(
|
||||
options.map(({ id, index, draft, duplicate }) => ({
|
||||
id,
|
||||
index,
|
||||
draft,
|
||||
duplicate,
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: "motion_on", index: 0, draft: false, duplicate: false },
|
||||
{ id: "sunset", index: 3, draft: false, duplicate: false },
|
||||
]);
|
||||
expect(options[0].trigger).toBe(motion);
|
||||
expect(getBlueprintTriggerIdOptions(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores null trigger entries from incomplete YAML", () => {
|
||||
const triggers = [
|
||||
null,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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,7 +13,16 @@ describe("security-view-strategy", () => {
|
||||
});
|
||||
hass.states[stateObj.entity_id] = stateObj;
|
||||
|
||||
expect(isSecurityPanelEntity(hass, stateObj)).toBe(true);
|
||||
expect(
|
||||
isSecurityPanelEntity(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
stateObj
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("renders active alerts as individual cards in a visible section", async () => {
|
||||
|
||||
Reference in new issue
Block a user