Compare commits

..
Author SHA1 Message Date
Petar Petrov a2fe8a2f6e Allow theming timeline colors of custom states
States of domains without state colors now use --state-<domain>-<state>-color (or its device class variant) when the theme defines it, before falling back to the graph palette.
2026-11-15 16:41:23 +02:00
renovate[bot] 0d31ae5624 Update dependency @rslib/core to v1.0.7 (#55925)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-15 14:24:47 +01:00
renovate[bot] f57384a265 Update dependency glob to v14.0.6 (#55926)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-15 14:24:40 +01:00
Tom Carpenter 4fe20d7712 Show Day of Week on Energy Period Selector card (#55924)
Show Day of Week on energy period selector card

When a single date is displayed, include day of week in the text.
2026-11-15 14:52:25 +02:00
Yosi LevyandCopilot Autofix powered by AI 14a4c05130 Marquee RTL support (#55876)
* Marquee RTL

* Refactor _animationFactor to a getter

Refactor _animationFactor to be a getter that determines its value based on document direction.

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-15 12:49:43 +00:00
Petar Petrov 2d6ebd26d9 Use measured battery flows on the energy dashboard (#55604)
* Move energy settings from dialogs to an editor sidebar

Sources and devices on all energy configuration tabs now open in a
sidebar like automation steps, with the selected row highlighted and a
save button for unsaved changes. Cost tracking and power measurement type
use dropdowns, and the editor inputs are grouped with dividers. The energy
setup wizard keeps its dialogs, which now reuse the same editors.

* Read the energy editors' data from contexts

The energy editors, the power measurement config and the upstream device
picker consume contexts instead of hass, so the setup wizard dialog no
longer passes it. createConfigFlow, handleConfigFlowStep and
getSensorDeviceClassConvertibleUnits take callApi or callWS.

The sidebar slots the editor in, so switching to the bottom sheet keeps
unsaved changes, and the lists are inert while the editor saves.

* Confirm unsaved energy edits on the back arrow

The toolbar back arrow asks before discarding the editor's changes, like
the automation editor does, and the sidebar is inert while the editor
saves, like the lists.

* Edit energy sources and devices on pages of their own

Sources and devices open on a page with a back arrow, their name as the
title and the form in a card, instead of in a sidebar. The lists go back
to the centered column, and the energy setup wizard keeps its dialogs.

* Keep energy page saves and deletes apart

Save and Delete are disabled while the other runs, since both write all
preferences, and a page that was left no longer navigates when its save
or delete finishes.

* Use measured battery flows on the energy dashboard

A battery can report its energy and power per direction: grid to
battery, solar to battery, battery to home and battery to grid. The
cards now use those flows as measured and estimate only the rest, so
grid charging no longer shows up as solar charging. Batteries with
total in and out statistics work as before.

The power Sankey now shares the energy flow math, and its type links
get explicit values instead of the greedy fill.

* Describe battery totals without a destination

* Show the energy error screen when statistics fail to load

Editor pages wait for the statistics metadata, so a failed request kept
them loading forever. It now shows the panel's error screen, like a
failed preferences or validation request.

* Name unnamed per-direction batteries in the power Sankey

* Don't register removed dirty state providers again

* Keep measured flows without a meter out of the home load

A measured battery flow whose grid or solar meter isn't configured never
passed through that meter, so it no longer changes the home load. The
power Sankey passes grid and solar as unmetered when they have no power
sensor.

* Open energy items through links

The editors open on pages now, so the list rows, the add buttons and the
CO2 signal row are links that can open in a new tab. ha-automation-row
gets an optional href that wraps the icon and header in a link and keeps
the icons slot outside of it. The setup wizard still opens dialogs.

* Note that a battery needs a charge and a discharge direction
2026-11-15 09:48:14 +01:00
Adam Pyle 5ba1190cdd Use theme colours on the Cast receiver launch screen (#55879)
The launch screen hardcoded a light background and dark text, so it
stayed white when the user or the cast device used dark mode. Use the
theme variables the receiver's ThemesMixin already applies to the
document, keeping the previous colours as fallbacks.
2026-11-15 08:34:09 +00:00
Petar Petrov 81cf8050e4 Drop the hass-shaped context from the security alerts editor (#55851)
Security entity helpers take states and registries as separate values.
2026-11-15 09:23:44 +01:00
Petar Petrov 1a10982a5e Fix the category filter scope round-trip and the navigation path registries consumer (#55848)
* Keep category filter list on a quick scope round-trip

Clear the category filter only when the scope differs from the subscribed one, and consume the registries context once in NavigationPathInfoController.

* Skip navigation path re-resolves when its inputs are unchanged

Each controller compares the values it resolved with, instead of sharing a module-level areas and devices cache.

* Clear the category filter list on disconnect

A reconnect with a new scope would otherwise show the old scope's categories until the new ones arrive.
2026-11-15 09:23:07 +01:00
Petar Petrov fb8ac756ce Pass callWS to getExtendedEntityRegistryEntry (#55849)
Take callWS instead of a hass-shaped object and use the helper again in callProtectedLockService.
2026-11-15 09:19:17 +01:00
renovate[bot] 2c072ab254 Update dependency prettier to v3.10.1 (#55923)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-15 08:13:11 +00:00
renovate[bot] 31ca8c298b Update pnpm to v12.20.0 (#55922)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-15 09:03:19 +01:00
Jan Bouwhuis 1a5d8884ad Align MQTT subscribe card width with other cards (#55914) 2026-11-15 08:34:13 +02:00
karwosts 9a95c9dea9 Fix targetless media selector (#55921) 2026-11-15 08:34:08 +02:00
renovate[bot] 5db5f9075d Update babel monorepo to v8.1.2 (#55917)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-15 08:25:22 +02:00
renovate[bot] 6ce043a8e4 Update dependency maplibre-gl to v6.18.0 (#55920)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-15 08:25:14 +02:00
renovate[bot] d81a9f29b2 Update dependency tar to v7.5.23 (#55919)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-15 08:25:05 +02:00
renovate[bot] c39734f350 Update dependency glob to v14.0.5 (#55918)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-15 08:24:52 +02:00
renovate[bot] e65584e724 Update dependency prettier to v3.10.0 (#55910)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-14 19:15:42 +01:00
renovate[bot] 6641a43e43 Update tsparticles to v4.5.4 (#55909)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-14 19:15:17 +01:00
renovate[bot] 2706bd89a1 Update dependency vite to v8.3.8 (#55906)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-14 08:55:55 +02:00
renovate[bot] bd434eeb0d Update dependency @rspack/core to v2.3.2 (#55899)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-13 17:11:21 +02:00
Petar Petrov 16f01e1081 Fix legends fitting as narrow in the card editor preview (#55886) 2026-11-13 14:28:15 +01:00
Dan SpencerandClaude Opus 5.5 050251db23 Offer a blueprint's trigger IDs in Triggered by conditions (#55802)
* Offer a blueprint's trigger IDs in Triggered by conditions

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

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

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

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

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

* Ignore an empty use_blueprint while editing YAML

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

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

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

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

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

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

* Address review: spinner, refetch warnings and index badges

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

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

* Show trigger IDs when trigger rows aren't numbered

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

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

* Say when a blueprint's triggers have no IDs

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

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

* Rename blueprint_triggers_pending to blueprint_triggers_unavailable

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

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

* Drop controller tests that cover one-line branches

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

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

---------

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

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

No files matched your search

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