Compare commits

...

19 Commits

Author SHA1 Message Date
dependabot[bot] 22786df070 Bump launch-editor from 2.13.2 to 2.14.1 (#52661)
Bumps [launch-editor](https://github.com/vitejs/launch-editor) from 2.13.2 to 2.14.1.
- [Commits](https://github.com/vitejs/launch-editor/compare/v2.13.2...v2.14.1)

---
updated-dependencies:
- dependency-name: launch-editor
  dependency-version: 2.14.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 21:18:48 +02:00
renovate[bot] e5c849359b Update eslint monorepo to v10.5.0 (#52659)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 20:09:16 +02:00
Abílio Costa 4bfa4f2816 Add legend filter to energy usage graph card (#52485) 2026-06-15 19:42:49 +02:00
Bram Kragten afd86975d6 Fix date dedupe in statistics chart (#52656) 2026-06-15 18:10:31 +02:00
Petar Petrov 7b1eff9eef Optimize energy gas graph card data generation (#52654) 2026-06-15 16:50:17 +02:00
Petar Petrov 4f0c228756 Optimize energy solar graph card data generation (#52653) 2026-06-15 16:49:55 +02:00
Petar Petrov c86101ac6e Optimize energy data processing (#52648) 2026-06-15 16:42:25 +02:00
Petar Petrov 29fa351b16 Optimize history data processing (#52646) 2026-06-15 16:37:25 +02:00
Petar Petrov 7c67633146 Optimize energy chart line gap filling (#52645) 2026-06-15 16:36:13 +02:00
Petar Petrov 180e23ad9b Optimize statistics chart data generation (#52644) 2026-06-15 16:35:34 +02:00
Franck Nijhof 9e7ddb3e5e Preserve unchanged device, area, and floor registry entries (#52655) 2026-06-15 16:04:40 +02:00
Aidan Timson 4a0e46dc2c Subsections for gallery sidebar (#52640)
Implement sections for gallery sidebar
2026-06-15 16:38:00 +03:00
Franck Nijhof 6af0040e73 Preserve unchanged entity display entries across registry updates (#52641)
* Preserve unchanged entity display entries across registry updates

* Compare all display fields (integration reload can change source-defined ones)

* Use a generic preserveUnchangedRecord helper with deepEqual
2026-06-15 13:35:50 +00:00
Aidan Timson ba58ef6dc2 Update gallery home page content (#52642) 2026-06-15 15:30:34 +03:00
Aidan Timson fafbd7a674 Migrate last set of dialogs to dirty state provider and dialog behavior (#52639) 2026-06-15 15:26:43 +03:00
Aidan Timson 07290a5d7e Migrate 6 dialogs to dirty state provider and dialog behavior (#52637)
Migrate more dialogs to dirty state provider and dialog behavior
2026-06-15 15:23:34 +03:00
Aidan Timson 06141043a7 Migrate registry dialogs to dirty state provider and dialog behavior (#52636) 2026-06-15 15:19:18 +03:00
Aidan Timson 03e4f968b4 Migrate calendar, todo, helper dialogs to dirty state provider and dialog behavior (#52634) 2026-06-15 15:16:25 +03:00
Aidan Timson 17d4f67f69 Migrate matter,zwave,zha dialogs to dirty state provider and dialog behavior (#52633) 2026-06-15 15:12:02 +03:00
58 changed files with 16993 additions and 760 deletions
+18 -1
View File
@@ -103,12 +103,29 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (!toProcess) {
console.error("Unknown category", group.category);
if (!group.pages) {
if (!group.subsections && !group.pages) {
group.pages = [];
}
continue;
}
if (group.subsections) {
// Listed pages keep their per-subsection order.
for (const subsection of group.subsections) {
for (const page of subsection.pages) {
if (!toProcess.delete(page)) {
console.error("Found unreferenced demo", page);
}
}
}
// Any remaining pages land in a trailing "Other" subsection.
const leftover = Array.from(toProcess).sort();
if (leftover.length) {
group.subsections.push({ header: "Other", pages: leftover });
}
continue;
}
// Any pre-defined groups will not be sorted.
if (group.pages) {
for (const page of group.pages) {
+11
View File
@@ -62,6 +62,17 @@ Use `sidebar.js` when a page needs a visible section, section header, or determi
- New categories without a sidebar entry are appended by the generator with their category name as the header.
- If a listed page does not exist, the generator logs an error during `gather-gallery-pages`.
### Subsections
A section can group its pages under named subsections instead of one flat list. Use this for large categories where related pages should sit together.
- `subsections` is an array of `{ header, pages }`. It is mutually exclusive with a flat `pages` array on the same group.
- Each subsection `header` is a non-collapsible label rendered inside the section's expansion panel; the section stays the only collapsible level.
- Listed pages keep their per-subsection order.
- Any pages found in the category but not listed in a subsection are collected into a generated `Other` subsection, appended alphabetically. The `Other` subsection is omitted when there are no leftovers.
- A listed page that does not exist still logs an error during `gather-gallery-pages`.
- Use sentence case for subsection headers and follow the content standards below.
## Markdown Pages
Use markdown pages for explanations, design guidance, API notes, and copy standards.
+164 -9
View File
@@ -10,6 +10,10 @@ import {
mdiViewDashboard,
} from "@mdi/js";
// A group may list its pages flat in `pages`, or group them under named
// `subsections`. The two are mutually exclusive. Listed pages keep their order;
// any pages found in the category but not listed are appended alphabetically
// (to a generated "Other" subsection when the group uses subsections).
export default [
{
// This section has no header and so all page links are shown directly in the sidebar
@@ -27,31 +31,162 @@ export default [
category: "components",
icon: mdiPuzzle,
header: "Components",
subsections: [
{
header: "Form and selectors",
pages: [
"ha-form",
"ha-selector",
"ha-select-box",
"ha-input",
"ha-textarea",
],
},
{
header: "Controls and sliders",
pages: [
"ha-button",
"ha-control-button",
"ha-progress-button",
"ha-switch",
"ha-control-switch",
"ha-slider",
"ha-control-slider",
"ha-control-circular-slider",
"ha-control-number-buttons",
"ha-control-select",
"ha-control-select-menu",
"ha-hs-color-picker",
],
},
{
header: "Overlays",
pages: [
"ha-dialog",
"ha-dialogs",
"ha-adaptive-dialog",
"ha-adaptive-popover",
"ha-dropdown",
"ha-tooltip",
],
},
{
header: "Lists and disclosure",
pages: ["ha-list", "ha-expansion-panel", "ha-faded"],
},
{
header: "Feedback and status",
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
},
{
header: "Labels and text",
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
},
],
},
{
category: "lovelace",
icon: mdiViewDashboard,
// Label for in the sidebar
header: "Dashboards",
// Specify order of pages. Any pages in the category folder but not listed here will
// automatically be added after the pages listed here.
pages: ["introduction"],
subsections: [
{
header: "Introduction",
pages: ["introduction"],
},
{
header: "Entity cards",
pages: [
"entities-card",
"entity-button-card",
"entity-filter-card",
"glance-card",
"tile-card",
"area-card",
],
},
{
header: "Picture cards",
pages: [
"picture-card",
"picture-elements-card",
"picture-entity-card",
"picture-glance-card",
],
},
{
header: "Domain cards",
pages: [
"light-card",
"thermostat-card",
"alarm-panel-card",
"gauge-card",
"plant-card",
"map-card",
"media-control-card",
"media-player-row",
],
},
{
header: "Layout and utility",
pages: [
"grid-and-stack-card",
"conditional-card",
"iframe-card",
"markdown-card",
"todo-list-card",
],
},
],
},
{
category: "more-info",
icon: mdiInformationOutline,
header: "More Info dialogs",
subsections: [
{
header: "Climate and water",
pages: ["climate", "humidifier", "water-heater", "fan"],
},
{
header: "Covers and access",
pages: ["cover", "lock", "lawn-mower", "vacuum"],
},
{
header: "Lighting",
pages: ["light", "scene"],
},
{
header: "Media",
pages: ["media-player"],
},
{
header: "Inputs and values",
pages: ["input-number", "input-text", "number", "timer"],
},
{
header: "System",
pages: ["update"],
},
],
},
{
category: "automation",
icon: mdiRobot,
header: "Automation",
pages: [
"editor-trigger",
"editor-condition",
"editor-action",
"trace",
"trace-timeline",
subsections: [
{
header: "Editors",
pages: ["editor-trigger", "editor-condition", "editor-action"],
},
{
header: "Descriptions",
pages: ["describe-trigger", "describe-condition", "describe-action"],
},
{
header: "Traces",
pages: ["trace", "trace-timeline"],
},
],
},
{
@@ -64,6 +199,26 @@ export default [
category: "date-time",
icon: mdiCalendarClock,
header: "Date and Time",
subsections: [
{
header: "Date",
pages: ["date"],
},
{
header: "Time",
pages: ["time", "time-seconds", "time-weekday"],
},
{
header: "Combined",
pages: [
"date-time",
"date-time-numeric",
"date-time-seconds",
"date-time-short",
"date-time-short-year",
],
},
],
},
{
category: "misc",
+60 -20
View File
@@ -40,15 +40,26 @@ interface GalleryPage {
demo?: unknown;
}
interface GallerySidebarSubsection {
header: string;
pages: string[];
}
interface GallerySidebarGroup {
category: string;
header?: string;
icon?: string;
pages: string[];
pages?: string[];
subsections?: GallerySidebarSubsection[];
}
const groupPages = (group: GallerySidebarGroup): string[] =>
group.subsections
? group.subsections.flatMap((subsection) => subsection.pages)
: (group.pages ?? []);
const GALLERY_SIDEBAR = SIDEBAR as GallerySidebarGroup[];
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${GALLERY_SIDEBAR[0].pages[0]}`;
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${groupPages(GALLERY_SIDEBAR[0])[0]}`;
const mql = matchMedia("(prefers-color-scheme: dark)");
@@ -284,26 +295,15 @@ class HaGallery extends LitElement {
const sidebar: unknown[] = [];
for (const group of GALLERY_SIDEBAR) {
const links: unknown[] = [];
const expanded = group.pages.some(
const expanded = groupPages(group).some(
(page) => this._page === `${group.category}/${page}`
);
for (const page of group.pages) {
const key = `${group.category}/${page}`;
if (!(key in PAGES)) {
console.error("Undefined page referenced in sidebar.js:", key);
continue;
}
links.push(
this._renderPageLink(
key,
PAGES[key].metadata.title || page,
group.header ? undefined : "main-navigation",
group.header ? undefined : group.icon
const content = group.subsections
? group.subsections.map((subsection) =>
this._renderSidebarSubsection(group, subsection)
)
);
}
: this._renderPageLinks(group, group.pages ?? []);
sidebar.push(
group.header
@@ -321,16 +321,46 @@ class HaGallery extends LitElement {
.path=${group.icon}
></ha-svg-icon>`
: nothing}
${links}
${content}
</ha-expansion-panel>
`
: links
: content
);
}
return sidebar;
}
private _renderSidebarSubsection(
group: GallerySidebarGroup,
subsection: GallerySidebarSubsection
) {
return html`
<div class="gallery-sidebar-subheader">${subsection.header}</div>
${this._renderPageLinks(group, subsection.pages)}
`;
}
private _renderPageLinks(group: GallerySidebarGroup, pages: string[]) {
const links: unknown[] = [];
for (const page of pages) {
const key = `${group.category}/${page}`;
if (!(key in PAGES)) {
console.error("Undefined page referenced in sidebar.js:", key);
continue;
}
links.push(
this._renderPageLink(
key,
PAGES[key].metadata.title || page,
group.header ? undefined : "main-navigation",
group.header ? undefined : group.icon
)
);
}
return links;
}
private _renderPageLink(
page: string,
title: string,
@@ -585,6 +615,16 @@ class HaGallery extends LitElement {
width: var(--ha-sidebar-expanded-section-item-width, 248px);
}
.gallery-sidebar-subheader {
margin: var(--ha-space-2) var(--ha-space-4) var(--ha-space-1);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.gallery-sidebar-icon,
.gallery-nav-item ha-svg-icon[slot="start"] {
color: var(--sidebar-icon-color);
+16 -7
View File
@@ -4,21 +4,30 @@ title: Home
# Welcome to Home Assistant Design
This portal aims to aid designers and developers on improving the Home Assistant interface. It consists of working code, resources and guidelines.
This is the design gallery for the Home Assistant frontend: a living reference of working components, dashboard cards, and brand and copy guidance. Every page runs outside a Home Assistant instance, so you can explore the interface, try components in isolation, and review changes against a consistent baseline.
## Home Assistant interface
## Browse the gallery
The Home Assistant frontend allows users to browse and control the state of their home, manage their automations and configure integrations. The frontend is designed as a mobile-first experience. It is a progressive web application and offers an app-like experience to our users. The Home Assistant frontend needs to be fast. But it also needs to work on a wide range of old devices.
- [Brand](#brand/logo): the logo, personality, and the story behind the Open Home.
- [Components](#components/ha-button): the `ha-*` component library with live demos and API notes.
- [Dashboards](#lovelace/introduction): Lovelace cards rendered from real card configuration.
- [More Info dialogs](#more-info/light): the more-info experience for each entity type.
- [Automation](#automation/editor-trigger): trigger, condition, and action editors, plus trace views.
- [Users](#user-test/user-types): the audiences we design for.
- [Date and time](#date-time/date): date and time formatting examples.
- [Miscellaneous](#misc/entity-state): smaller utilities and patterns, plus how to edit this gallery.
### Material Design
## Testing and playground
The Home Assistant interface is based on Material Design. It's a design system created by Google to quickly build high-quality digital experiences. Components and guidelines that are custom made for Home Assistant are documented on this portal. For all other components check <a href="https://material.io" rel="noopener noreferrer" target="_blank">material.io</a>.
Every page runs against fake state, so you can interact with components safely and reproducibly. Treat the demo pages as a playground: change a value, resize the window, or switch the layout to right-to-left to check spacing and direction. Use the gallery to reproduce a UI state in isolation before debugging it in a full Home Assistant setup.
Open **Settings** from the gear icon in the sidebar to switch between light and dark themes or preview the interface in right-to-left.
## Designers
We want to make it as easy for designers to contribute as it is for developers. Theres a lot a designer can contribute to:
We want to make it as easy for designers to contribute as it is for developers. There's a lot a designer can contribute to:
- Meet us at <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
- Meet us in the <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
- Start designing with our <a href="https://www.figma.com/design/2WGI8IDGyxINjSV6NRvPur/Home-Assistant-Design-Kit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
- Find the latest UX <a href="https://github.com/home-assistant/frontend/discussions?discussions_q=label%3Aux" rel="noopener noreferrer" target="_blank">discussions</a> and <a href="https://github.com/home-assistant/frontend/labels/ux" rel="noopener noreferrer" target="_blank">issues</a> on GitHub. Everyone can start a new issue or discussion!
+1 -1
View File
@@ -159,7 +159,7 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.4.1",
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
@@ -0,0 +1,29 @@
/**
* Records like the entity, device, area and floor registries are re-fetched and
* rebuilt in full on every registry-updated event, producing brand-new objects
* for every item even when nothing relevant changed. That gives every item a new
* reference, so all consumers needlessly re-render.
*
* Returns `next` with each item replaced by the equal `previous` item, so
* unchanged items keep their object identity, and returns the `previous` record
* untouched when nothing changed at all (so the update can be skipped entirely).
*/
export const preserveUnchangedRecord = <T>(
previous: Record<string, T> | undefined,
next: Record<string, T>,
equal: (a: T, b: T) => boolean
): Record<string, T> => {
if (!previous) {
return next;
}
let changed = Object.keys(previous).length !== Object.keys(next).length;
for (const key of Object.keys(next)) {
const previousItem = previous[key];
if (previousItem !== undefined && equal(previousItem, next[key])) {
next[key] = previousItem;
} else {
changed = true;
}
}
return changed ? next : previous;
};
+76 -47
View File
@@ -162,35 +162,40 @@ export function generateStatisticsChartData(
// endTime is "now" and client time is not in sync with server time.
return;
}
statDataSets.forEach((d, i) => {
if (chartType === "line") {
if (
prevEndTime &&
prevValues &&
prevEndTime.getTime() !== start.getTime()
) {
const isLineChart = chartType === "line";
// For bar charts, optionally center the bar within its time range. The
// centered time is shared by every series of this data point.
const barTime =
!isLineChart && centerBars
? new Date((start.getTime() + end.getTime()) / 2)
: start;
// Whether a gap needs to be drawn before this data point (line charts).
const drawGap =
isLineChart &&
!!prevEndTime &&
!!prevValues &&
prevEndTime.getTime() !== start.getTime();
for (let i = 0; i < statDataSets.length; i++) {
const d = statDataSets[i];
const dataValue = dataValues[i];
if (isLineChart) {
if (drawGap) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
d.data!.push([prevEndTime!, ...prevValues![i]!]);
d.data!.push([prevEndTime!, null]);
}
d.data!.push([start, ...dataValues[i]!]);
d.data!.push([start, ...dataValue!]);
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
// the last element. For regular rows it's [value]. Same call works.
trackY(dataValues[i][dataValues[i].length - 1]);
trackY(dataValue[dataValue.length - 1]);
} else {
let time = start;
if (centerBars) {
// If centering bars, set the time to the midpoint between start and end instead
// of the start time.
time = new Date((start.getTime() + end.getTime()) / 2);
}
// Data value should always be a scalar for bar charts. Pass in
// real start time as extra value to allow formatting tooltip.
d.data!.push([time, dataValues[i][0]!, start, end]);
trackY(dataValues[i][0]);
d.data!.push([barTime, dataValue[0]!, start, end]);
trackY(dataValue[0]);
}
});
}
prevValues = dataValues;
prevEndTime = limit;
};
@@ -314,44 +319,68 @@ export function generateStatisticsChartData(
}
});
let prevDate: Date | null = null;
let prevStart: number | null = null;
// Process chart data.
let firstSum: number | null | undefined = null;
stats.forEach((stat) => {
// The per-type branch decisions in the inner loop are invariant across all
// stats of this statistic, so classify each type once up front.
// kind: 0 = sum (cumulative diff), 1 = band-top ([diff, top]), 2 = plain.
const SUM_KIND = 0;
const BAND_KIND = 1;
const PLAIN_KIND = 2;
const bandBottomHidden = hiddenStats.has(`${statistic_id}-${bandBottom}`);
const isLine = chartType === "line";
const typeKinds = statTypes.map((type) => {
if (type === "sum") {
return SUM_KIND;
}
if (type === bandTop && isLine && drawBands && !bandBottomHidden) {
return BAND_KIND;
}
return PLAIN_KIND;
});
const numTypes = statTypes.length;
const statHidden = hiddenStats.has(statistic_id);
for (const stat of stats) {
// Skip consecutive stats that share the same start time. Compare the raw
// numeric start so the dedup actually fires (a `Date` reference compare
// never would) and so we skip allocating a `Date` on the dropped path.
if (prevStart === stat.start) {
continue;
}
prevStart = stat.start;
const startDate = new Date(stat.start);
const endDate = new Date(stat.end);
if (prevDate === startDate) {
return;
}
prevDate = startDate;
const dataValues: (number | null)[][] = [];
statTypes.forEach((type) => {
for (let t = 0; t < numTypes; t++) {
const type = statTypes[t];
const val: (number | null)[] = [];
if (type === "sum") {
if (firstSum === null || firstSum === undefined) {
val.push(0);
firstSum = stat.sum;
} else {
val.push((stat.sum || 0) - firstSum);
switch (typeKinds[t]) {
case SUM_KIND:
if (firstSum === null || firstSum === undefined) {
val.push(0);
firstSum = stat.sum;
} else {
val.push((stat.sum || 0) - firstSum);
}
break;
case BAND_KIND: {
const top = stat[bandTop] || 0;
val.push(Math.abs(top - (stat[bandBottom] || 0)));
val.push(top);
break;
}
} else if (
type === bandTop &&
chartType === "line" &&
drawBands &&
!hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
const top = stat[bandTop] || 0;
val.push(Math.abs(top - (stat[bandBottom] || 0)));
val.push(top);
} else {
val.push(stat[type] ?? null);
default:
val.push(stat[type] ?? null);
}
dataValues.push(val);
});
if (!hiddenStats.has(statistic_id)) {
}
if (!statHidden) {
pushData(startDate, endDate, endTime, dataValues);
}
});
}
// For line charts, close out the last stat segment at prevEndTime
const lastEndTime = prevEndTime;
+20 -16
View File
@@ -1121,14 +1121,12 @@ const getSummedDataPartial = (
const timestamps = new Set<number>();
Object.entries(statIds).forEach(([key, subStatIds]) => {
const totalStats: Record<number, number> = {};
const sets: Record<string, Record<number, number>> = {};
let sum = 0;
subStatIds!.forEach((id) => {
const stats = compare ? data.statsCompare[id] : data.stats[id];
if (!stats) {
return;
}
const set = {};
stats.forEach((stat) => {
if (stat.change === null || stat.change === undefined) {
return;
@@ -1139,7 +1137,6 @@ const getSummedDataPartial = (
stat.start in totalStats ? totalStats[stat.start] + val : val;
timestamps.add(stat.start);
});
sets[id] = set;
});
summedData[key] = totalStats;
summedData.total[key] = sum;
@@ -1190,6 +1187,13 @@ const computeConsumptionDataPartial = (
},
};
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 total = outData.total;
data.timestamps.forEach((t) => {
const {
grid_to_battery,
@@ -1201,29 +1205,29 @@ const computeConsumptionDataPartial = (
solar_to_battery,
solar_to_grid,
} = computeConsumptionSingle({
from_grid: data.from_grid && (data.from_grid[t] ?? 0),
to_grid: data.to_grid && (data.to_grid[t] ?? 0),
solar: data.solar && (data.solar[t] ?? 0),
to_battery: data.to_battery && (data.to_battery[t] ?? 0),
from_battery: data.from_battery && (data.from_battery[t] ?? 0),
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),
});
outData.used_total[t] = used_total;
outData.total.used_total += used_total;
total.used_total += used_total;
outData.grid_to_battery[t] = grid_to_battery;
outData.total.grid_to_battery += grid_to_battery;
total.grid_to_battery += grid_to_battery;
outData.battery_to_grid![t] = battery_to_grid;
outData.total.battery_to_grid += battery_to_grid;
total.battery_to_grid += battery_to_grid;
outData.used_battery![t] = used_battery;
outData.total.used_battery += used_battery;
total.used_battery += used_battery;
outData.used_grid![t] = used_grid;
outData.total.used_grid += used_grid;
total.used_grid += used_grid;
outData.used_solar![t] = used_solar;
outData.total.used_solar += used_solar;
total.used_solar += used_solar;
outData.solar_to_battery[t] = solar_to_battery;
outData.total.solar_to_battery += solar_to_battery;
total.solar_to_battery += solar_to_battery;
outData.solar_to_grid[t] = solar_to_grid;
outData.total.solar_to_grid += solar_to_grid;
total.solar_to_grid += solar_to_grid;
});
return outData;
+60 -50
View File
@@ -164,60 +164,70 @@ export class HistoryStream {
? (new Date().getTime() - 60 * 60 * this.hoursToShow * 1000) / 1000
: undefined;
const newHistory: HistoryStates = {};
for (const entityId of Object.keys(this.combinedHistory)) {
newHistory[entityId] = [];
}
for (const entityId of Object.keys(streamMessage.states)) {
newHistory[entityId] = [];
}
for (const entityId of Object.keys(newHistory)) {
if (
entityId in this.combinedHistory &&
entityId in streamMessage.states
) {
// Build the union of entity ids (existing first, then new ones) in a
// single pass and process each entity inline. The per-entity slot is
// always assigned below before being read, so there is no need to
// pre-seed every key with an empty array first.
const streamStates = streamMessage.states;
const processEntity = (entityId: string) => {
const inCombined = entityId in this.combinedHistory;
const inStream = entityId in streamStates;
if (inCombined && inStream) {
const entityCombinedHistory = this.combinedHistory[entityId];
const lastEntityCombinedHistory =
entityCombinedHistory[entityCombinedHistory.length - 1];
newHistory[entityId] = entityCombinedHistory.concat(
streamMessage.states[entityId]
streamStates[entityId]
);
if (
streamMessage.states[entityId][0].lu < lastEntityCombinedHistory.lu
) {
if (streamStates[entityId][0].lu < lastEntityCombinedHistory.lu) {
// If the history is out of order we have to sort it.
newHistory[entityId] = newHistory[entityId].sort(
(a, b) => a.lu - b.lu
);
}
} else if (entityId in this.combinedHistory) {
} else if (inCombined) {
newHistory[entityId] = this.combinedHistory[entityId];
} else {
newHistory[entityId] = streamMessage.states[entityId];
newHistory[entityId] = streamStates[entityId];
return;
}
// Remove old history
if (purgeBeforePythonTime && entityId in this.combinedHistory) {
const expiredStates = newHistory[entityId].filter(
(state) => state.lu < purgeBeforePythonTime
);
if (!expiredStates.length) {
continue;
// Remove old history (only entities present in combinedHistory reach
// here without an early return).
if (purgeBeforePythonTime) {
// Single pass: split into kept (lu >= cutoff, preserving order) and
// track the last expired state (lu < cutoff) without allocating a
// second array.
const states = newHistory[entityId];
const kept: EntityHistoryState[] = [];
let lastExpiredState: EntityHistoryState | undefined;
for (const state of states) {
if (state.lu < purgeBeforePythonTime) {
lastExpiredState = state;
} else {
kept.push(state);
}
}
newHistory[entityId] = newHistory[entityId].filter(
(state) => state.lu >= purgeBeforePythonTime
);
if (
newHistory[entityId].length &&
newHistory[entityId][0].lu === purgeBeforePythonTime
) {
continue;
if (!lastExpiredState) {
return;
}
newHistory[entityId] = kept;
if (kept.length && kept[0].lu === purgeBeforePythonTime) {
return;
}
// Update the first entry to the start time state
// as we need to preserve the start time state and
// only expire the rest of the history as it ages.
const lastExpiredState = expiredStates[expiredStates.length - 1];
lastExpiredState.lu = purgeBeforePythonTime;
delete lastExpiredState.lc;
newHistory[entityId].unshift(lastExpiredState);
kept.unshift(lastExpiredState);
}
};
for (const entityId of Object.keys(this.combinedHistory)) {
processEntity(entityId);
}
for (const entityId of Object.keys(streamStates)) {
if (!(entityId in this.combinedHistory)) {
processEntity(entityId);
}
}
this.combinedHistory = newHistory;
@@ -381,16 +391,18 @@ const processLineChartEntities = (
): LineChartUnit => {
const data: LineChartEntity[] = [];
Object.keys(entities).forEach((entityId) => {
const entityIds = Object.keys(entities);
entityIds.forEach((entityId) => {
const states = entities[entityId];
const first: EntityHistoryState = states[0];
const domain = computeDomain(entityId);
const useLastUpdated = DOMAINS_USE_LAST_UPDATED.includes(domain);
const processedStates: LineChartState[] = [];
for (const state of states) {
let processedState: LineChartState;
if (DOMAINS_USE_LAST_UPDATED.includes(domain)) {
if (useLastUpdated) {
processedState = {
state: state.s,
last_changed: state.lu * 1000,
@@ -412,13 +424,11 @@ const processLineChartEntities = (
};
}
const len = processedStates.length;
if (
processedStates.length > 1 &&
equalState(
processedState,
processedStates[processedStates.length - 1]
) &&
equalState(processedState, processedStates[processedStates.length - 2])
len > 1 &&
equalState(processedState, processedStates[len - 1]) &&
equalState(processedState, processedStates[len - 2])
) {
continue;
}
@@ -444,11 +454,17 @@ const processLineChartEntities = (
return {
unit,
device_class,
identifier: Object.keys(entities).join(""),
identifier: entityIds.join(""),
data,
};
};
const SPECIAL_DOMAIN_CLASSES: Record<string, string | undefined> = {
climate: "temperature",
humidifier: "humidity",
water_heater: "temperature",
};
const NUMERICAL_DOMAINS = ["counter", "input_number", "number"];
const isNumericFromDomain = (domain: string) =>
@@ -593,14 +609,8 @@ export const computeHistory = (
}[domain];
}
const specialDomainClasses = {
climate: "temperature",
humidifier: "humidity",
water_heater: "temperature",
};
const deviceClass: string | undefined =
specialDomainClasses[domain] ||
SPECIAL_DOMAIN_CLASSES[domain] ||
(currentState?.attributes || numericStateFromHistory?.a)?.device_class;
const key = computeGroupKey(unit, deviceClass, splitDeviceClasses);
+8 -2
View File
@@ -7,6 +7,7 @@ import "../../components/ha-button";
import "../../components/ha-form/ha-form";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
@@ -20,7 +21,7 @@ interface StackEntry {
@customElement("dialog-form")
export class DialogForm
extends LitElement
extends DirtyStateProviderMixin<FormDialogData>()(LitElement)
implements HassDialog<FormDialogData>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -39,6 +40,7 @@ export class DialogForm
this._params = params;
this._data = params.data || {};
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): boolean {
@@ -62,6 +64,7 @@ export class DialogForm
const nested = ev.detail.dialogParams as FormDialogParams;
this._params = nested;
this._data = nested?.data || {};
this._initDirtyTracking({ type: "deep" }, this._data);
};
private _popStack(): string | undefined {
@@ -72,6 +75,7 @@ export class DialogForm
this._stack = this._stack.slice(0, -1);
this._params = prev.params;
this._data = prev.data;
this._initDirtyTracking({ type: "deep" }, this._data);
return prev.nestedField;
}
@@ -115,6 +119,7 @@ export class DialogForm
: data;
this._data = deepClone({ ...this._data, [nestedField]: newValue });
this._updateDirtyState(this._data);
}
private _cancel(): void {
@@ -131,6 +136,7 @@ export class DialogForm
private _valueChanged(ev: CustomEvent): void {
this._data = ev.detail.value;
this._updateDirtyState(this._data);
}
protected render() {
@@ -142,7 +148,7 @@ export class DialogForm
<ha-dialog
.open=${this._open}
header-title=${this._params.title}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-form
+17 -2
View File
@@ -13,11 +13,18 @@ import "../../components/ha-textarea";
import type { HaTextArea } from "../../components/ha-textarea";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import type { HomeAssistant } from "../../types";
import type { DialogBoxParams } from "./show-dialog-box";
interface DialogBoxDirtyState {
value: string;
}
@customElement("dialog-box")
class DialogBox extends LitElement {
class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DialogBoxParams;
@@ -43,6 +50,10 @@ class DialogBox extends LitElement {
this._params = params;
this._validInput = true;
this._open = true;
this._initDirtyTracking(
{ type: "deep" },
{ value: params.defaultValue ?? "" }
);
await this.updateComplete;
this._validateInput();
}
@@ -77,7 +88,7 @@ class DialogBox extends LitElement {
<ha-dialog
.open=${this._open}
type=${confirmPrompt ? "alert" : "standard"}
?prevent-scrim-close=${confirmPrompt}
.preventScrimClose=${!!this._params.confirmation || this.isDirtyState}
@closed=${this._dialogClosed}
aria-labelledby="dialog-box-title"
aria-describedby="dialog-box-description"
@@ -212,6 +223,7 @@ class DialogBox extends LitElement {
if (this._params!.confirm) {
this._params!.confirm(this._textField?.value);
}
this._markDirtyStateClean();
this._closeDialog();
}
@@ -219,6 +231,9 @@ class DialogBox extends LitElement {
this._validInput = this._params?.prompt
? (this._textField?.checkValidity() ?? true)
: true;
if (this._params?.prompt) {
this._updateDirtyState({ value: this._textField?.value ?? "" });
}
}
private _closeDialog() {
@@ -9,6 +9,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../make-dialog-manager";
@@ -16,7 +17,7 @@ import type { HaImageCropperDialogParams } from "./show-image-cropper-dialog";
@customElement("image-cropper-dialog")
export class HaImagecropperDialog
extends LitElement
extends DirtyStateProviderMixin<Cropper.Data>()(LitElement)
implements HassDialog<HaImageCropperDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -29,6 +30,8 @@ export class HaImagecropperDialog
private _cropper?: Cropper;
private _cropReady = false;
@state() private _isTargetAspectRatio?: boolean;
public showDialog(params: HaImageCropperDialogParams): void {
@@ -40,6 +43,7 @@ export class HaImagecropperDialog
this._open = false;
this._cropper?.destroy();
this._cropper = undefined;
this._cropReady = false;
this._isTargetAspectRatio = false;
return true;
}
@@ -63,6 +67,13 @@ export class HaImagecropperDialog
ready: () => {
this._isTargetAspectRatio = this._checkMatchAspectRatio();
URL.revokeObjectURL(this._image!.src);
this._initDirtyTracking({ type: "deep" }, this._cropper!.getData());
this._cropReady = true;
},
crop: () => {
if (this._cropReady) {
this._updateDirtyState(this._cropper!.getData());
}
},
});
} else {
@@ -100,6 +111,7 @@ export class HaImagecropperDialog
header-title=${this.hass.localize(
"ui.dialogs.image_cropper.crop_image"
)}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div
@@ -14,6 +14,7 @@ import {
lightSupportsColor,
lightSupportsColorMode,
} from "../../../../data/light";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "./light-color-rgb-picker";
@@ -22,8 +23,14 @@ import type { LightColorFavoriteDialogParams } from "./show-dialog-light-color-f
export type LightPickerMode = "color_temp" | "color";
interface LightColorFavoriteState {
color?: LightColor;
}
@customElement("dialog-light-color-favorite")
class DialogLightColorFavorite extends LitElement {
class DialogLightColorFavorite extends DirtyStateProviderMixin<LightColorFavoriteState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() _dialogParams?: LightColorFavoriteDialogParams;
@@ -46,6 +53,7 @@ class DialogLightColorFavorite extends LitElement {
this._color = dialogParams.initialColor ?? this._computeCurrentColor();
this._updateModes();
this._open = true;
this._initDirtyTracking({ type: "deep" }, { color: this._color });
}
public closeDialog(): void {
@@ -107,6 +115,7 @@ class DialogLightColorFavorite extends LitElement {
private _colorChanged(ev: CustomEvent) {
this._color = ev.detail;
this._updateDirtyState({ color: this._color });
}
get stateObj() {
@@ -130,6 +139,7 @@ class DialogLightColorFavorite extends LitElement {
return;
}
this._dialogParams?.submit?.(this._color);
this._markDirtyStateClean();
this.closeDialog();
}
@@ -150,6 +160,7 @@ class DialogLightColorFavorite extends LitElement {
<ha-dialog
.open=${this._open}
.headerTitle=${this._dialogParams?.title}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div class="header">
+43 -20
View File
@@ -29,11 +29,19 @@ import {
getPanelIconPath,
getPanelTitle,
} from "../../data/panel";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { showConfirmationDialog } from "../generic/show-dialog-box";
interface SidebarState {
order: string[];
hidden: string[];
}
@customElement("dialog-edit-sidebar")
class DialogEditSidebar extends LitElement {
class DialogEditSidebar extends DirtyStateProviderMixin<SidebarState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -71,6 +79,11 @@ class DialogEditSidebar extends LitElement {
this._migrateToUserData = this._migrateToUserData || !!storedHidden;
this._hidden = storedHidden ? JSON.parse(storedHidden) : [];
}
const order = this._order ?? [];
this._initDirtyTracking(
{ type: "deep" },
{ order, hidden: this._computeHiddenPanels() }
);
} catch (err: any) {
this._error = err.message || err;
}
@@ -89,6 +102,30 @@ class DialogEditSidebar extends LitElement {
panels ? Object.values(panels) : []
);
private _computeHiddenPanels(): string[] {
const panels = this._panels(this.hass.panels);
const defaultPanel = getDefaultPanelUrlPath(this.hass);
const orderSet = new Set(this._order);
const hiddenSet = new Set(this._hidden);
for (const panel of panels) {
if (
panel.default_visible === false &&
!orderSet.has(panel.url_path) &&
!hiddenSet.has(panel.url_path)
) {
hiddenSet.add(panel.url_path);
}
}
if (hiddenSet.has(defaultPanel)) {
hiddenSet.delete(defaultPanel);
}
return Array.from(hiddenSet);
}
private _renderContent(): TemplateResult {
if (!this._order || !this._hidden) {
return html`<ha-fade-in .delay=${500}
@@ -112,24 +149,7 @@ class DialogEditSidebar extends LitElement {
this.hass.locale
);
const orderSet = new Set(this._order);
const hiddenSet = new Set(this._hidden);
for (const panel of panels) {
if (
panel.default_visible === false &&
!orderSet.has(panel.url_path) &&
!hiddenSet.has(panel.url_path)
) {
hiddenSet.add(panel.url_path);
}
}
if (hiddenSet.has(defaultPanel)) {
hiddenSet.delete(defaultPanel);
}
const hiddenPanels = Array.from(hiddenSet);
const hiddenPanels = this._computeHiddenPanels();
const items = [
...beforeSpacer,
@@ -169,6 +189,7 @@ class DialogEditSidebar extends LitElement {
header-subtitle=${!this._migrateToUserData
? this.hass.localize("ui.sidebar.edit_subtitle")
: ""}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-dropdown slot="headerActionItems" placement="bottom-end">
@@ -193,7 +214,7 @@ class DialogEditSidebar extends LitElement {
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!this._order || !this._hidden}
.disabled=${!this._order || !this._hidden || !this.isDirtyState}
@click=${this._save}
>
${this.hass.localize("ui.common.save")}
@@ -207,6 +228,7 @@ class DialogEditSidebar extends LitElement {
const { order = [], hidden = [] } = ev.detail.value;
this._order = [...order];
this._hidden = [...hidden];
this._updateDirtyState({ order: this._order, hidden: this._hidden });
}
private _resetToDefaults = async () => {
@@ -250,6 +272,7 @@ class DialogEditSidebar extends LitElement {
return;
}
this._markDirtyStateClean();
this.closeDialog();
}
@@ -39,6 +39,7 @@ import {
deleteCalendarEvent,
updateCalendarEvent,
} from "../../data/calendar";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../lovelace/components/hui-generic-entity-row";
@@ -48,8 +49,21 @@ import type { CalendarEventEditDialogParams } from "./show-dialog-calendar-event
const CALENDAR_DOMAINS = ["calendar"];
interface CalendarEventFormState {
calendarId?: string;
summary: string;
description?: string;
location?: string;
rrule?: string;
allDay: boolean;
dtstart?: Date;
dtend?: Date;
}
@customElement("dialog-calendar-event-editor")
class DialogCalendarEventEditor extends LitElement {
class DialogCalendarEventEditor extends DirtyStateProviderMixin<CalendarEventFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: string;
@@ -130,6 +144,20 @@ class DialogCalendarEventEditor extends LitElement {
);
this._dtend = addHours(this._dtstart, 1);
}
this._initDirtyTracking({ type: "deep" }, this._currentState());
}
private _currentState(): CalendarEventFormState {
return {
calendarId: this._calendarId,
summary: this._summary,
description: this._description,
location: this._location,
rrule: this._rrule,
allDay: this._allDay,
dtstart: this._dtstart,
dtend: this._dtend,
};
}
public closeDialog(): void {
@@ -153,7 +181,7 @@ class DialogCalendarEventEditor extends LitElement {
header-title=${this.hass.localize(
`ui.components.calendar.event.${isCreate ? "add" : "edit"}`
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div class="content">
@@ -277,7 +305,7 @@ class DialogCalendarEventEditor extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._createEvent}
.disabled=${this._submitting}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize("ui.components.calendar.event.add")}
</ha-button>
@@ -301,7 +329,7 @@ class DialogCalendarEventEditor extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._saveEvent}
.disabled=${this._submitting}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize("ui.components.calendar.event.save")}
</ha-button>
@@ -346,18 +374,22 @@ class DialogCalendarEventEditor extends LitElement {
private _handleSummaryChanged(ev) {
this._summary = ev.target.value;
this._updateDirtyState(this._currentState());
}
private _handleDescriptionChanged(ev) {
this._description = ev.target.value;
this._updateDirtyState(this._currentState());
}
private _handleLocationChanged(ev: Event) {
this._location = (ev.target as HTMLInputElement).value;
this._updateDirtyState(this._currentState());
}
private _handleRRuleChanged(ev) {
this._rrule = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _allDayToggleChanged(ev) {
@@ -371,6 +403,7 @@ class DialogCalendarEventEditor extends LitElement {
formatDate(this._dtend, this._timeZone!) + "T00:00:00"
);
}
this._updateDirtyState(this._currentState());
}
private _startDateChanged(ev: CustomEvent) {
@@ -390,6 +423,7 @@ class DialogCalendarEventEditor extends LitElement {
"ui.components.calendar.event.end_auto_adjusted"
);
}
this._updateDirtyState(this._currentState());
}
private _endDateChanged(ev: CustomEvent) {
@@ -397,6 +431,7 @@ class DialogCalendarEventEditor extends LitElement {
`${ev.detail.value}T${formatTime(this._dtend!, this._timeZone!)}`,
this._timeZone!
);
this._updateDirtyState(this._currentState());
}
private _startTimeChanged(ev: CustomEvent) {
@@ -416,6 +451,7 @@ class DialogCalendarEventEditor extends LitElement {
"ui.components.calendar.event.end_auto_adjusted"
);
}
this._updateDirtyState(this._currentState());
}
private _endTimeChanged(ev: CustomEvent) {
@@ -423,6 +459,7 @@ class DialogCalendarEventEditor extends LitElement {
`${formatDate(this._dtend!, this._timeZone!)}T${ev.detail.value}`,
this._timeZone!
);
this._updateDirtyState(this._currentState());
}
private _calculateData() {
@@ -453,6 +490,7 @@ class DialogCalendarEventEditor extends LitElement {
private _handleCalendarChanged(ev: CustomEvent) {
this._calendarId = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _isValidStartEnd(): boolean {
@@ -10,10 +10,17 @@ import type { SchemaUnion } from "../../../../../components/ha-form/types";
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import { addHassioDockerRegistry } from "../../../../../data/hassio/docker";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { RegistryDialogParams } from "./show-dialog-registries";
interface RegistryInput {
registry?: string;
username?: string;
password?: string;
}
const SCHEMA = [
{
name: "registry",
@@ -33,16 +40,14 @@ const SCHEMA = [
] as const;
@customElement("dialog-apps-registries")
class AppsRegistriesDialog extends LitElement {
class AppsRegistriesDialog extends DirtyStateProviderMixin<RegistryInput>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _dialogParams?: RegistryDialogParams;
@state() private _input: {
registry?: string;
username?: string;
password?: string;
} = {};
@state() private _input: RegistryInput = {};
@state() private _open = false;
@@ -52,6 +57,7 @@ class AppsRegistriesDialog extends LitElement {
this._dialogParams = dialogParams;
this._open = true;
this._input = {};
this._initDirtyTracking({ type: "deep" }, this._input);
await this.updateComplete;
}
@@ -75,6 +81,7 @@ class AppsRegistriesDialog extends LitElement {
header-title=${this.hass.localize(
"ui.panel.config.apps.registries.add_title"
)}
.preventScrimClose=${this.isDirtyState}
>
<ha-form
autofocus
@@ -112,6 +119,7 @@ class AppsRegistriesDialog extends LitElement {
private _valueChanged(ev: CustomEvent) {
this._input = ev.detail.value;
this._updateDirtyState(this._input);
}
private async _addRegistry(): Promise<void> {
@@ -125,6 +133,7 @@ class AppsRegistriesDialog extends LitElement {
try {
await addHassioDockerRegistry(this.hass, data);
this._dialogParams?.registryAdded?.();
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
showAlertDialog(this, {
@@ -25,6 +25,7 @@ import {
updateAreaRegistryEntry,
} from "../../../data/area/area_registry";
import { reorderFloorRegistryEntries } from "../../../data/floor_registry";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
@@ -37,8 +38,15 @@ interface FloorChange {
floorId: string | null;
}
interface OrderState {
floors: { id: string; areas: string[] }[];
areas: string[];
}
@customElement("dialog-areas-floors-order")
class DialogAreasFloorsOrder extends LitElement {
class DialogAreasFloorsOrder extends DirtyStateProviderMixin<OrderState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -59,6 +67,20 @@ class DialogAreasFloorsOrder extends LitElement {
Object.values(this.hass.floors),
Object.values(this.hass.areas)
);
this._initDirtyTracking({ type: "deep" }, this._currentOrder());
}
private _currentOrder(): OrderState {
if (!this._hierarchy) {
return { floors: [], areas: [] };
}
return {
floors: this._hierarchy.floors.map((floor) => ({
id: floor.id,
areas: [...floor.areas],
})),
areas: [...this._hierarchy.areas],
};
}
public closeDialog(): void {
@@ -89,6 +111,7 @@ class DialogAreasFloorsOrder extends LitElement {
<ha-dialog
.open=${this._open}
header-title=${dialogTitle}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div class="content">
@@ -119,7 +142,7 @@ class DialogAreasFloorsOrder extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._saving}
.disabled=${this._saving || !this.isDirtyState}
>
${this.hass.localize("ui.common.save")}
</ha-button>
@@ -241,6 +264,7 @@ class DialogAreasFloorsOrder extends LitElement {
...this._hierarchy,
floors: newFloors,
};
this._updateDirtyState(this._currentOrder());
}
private _areaMoved(ev: CustomEvent): void {
@@ -278,6 +302,7 @@ class DialogAreasFloorsOrder extends LitElement {
}),
};
}
this._updateDirtyState(this._currentOrder());
}
private _areaAdded(ev: CustomEvent): void {
@@ -320,6 +345,7 @@ class DialogAreasFloorsOrder extends LitElement {
}),
areas: newUnassignedAreas,
};
this._updateDirtyState(this._currentOrder());
}
private _computeFloorChanges(): FloorChange[] {
@@ -375,6 +401,7 @@ class DialogAreasFloorsOrder extends LitElement {
await reorderAreaRegistryEntries(this.hass, areaOrder);
await reorderFloorRegistryEntries(this.hass, floorOrder);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
showToast(this, {
@@ -24,13 +24,25 @@ import type {
FloorRegistryEntry,
FloorRegistryEntryMutableParams,
} from "../../../data/floor_registry";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showAreaRegistryDetailDialog } from "./show-dialog-area-registry-detail";
import type { FloorRegistryDetailDialogParams } from "./show-dialog-floor-registry-detail";
interface FloorFormState {
name: string;
aliases: string[];
icon: string | null;
level: number | null;
addedAreas: string[];
removedAreas: string[];
}
@customElement("dialog-floor-registry-detail")
class DialogFloorDetail extends LitElement {
class DialogFloorDetail extends DirtyStateProviderMixin<FloorFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _name!: string;
@@ -65,6 +77,18 @@ class DialogFloorDetail extends LitElement {
this._addedAreas.clear();
this._removedAreas.clear();
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
}
private _currentState(): FloorFormState {
return {
name: this._name,
aliases: this._aliases,
icon: this._icon,
level: this._level,
addedAreas: [...this._addedAreas].sort(),
removedAreas: [...this._removedAreas].sort(),
};
}
public closeDialog(): void {
@@ -112,7 +136,7 @@ class DialogFloorDetail extends LitElement {
header-title=${entry
? this.hass.localize("ui.panel.config.floors.editor.update_floor")
: this.hass.localize("ui.panel.config.floors.editor.create_floor")}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div>
@@ -245,7 +269,8 @@ class DialogFloorDetail extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!!this._submitting}
.disabled=${!!this._submitting ||
(!!this._params?.entry && !this.isDirtyState)}
>
${entry
? this.hass.localize("ui.common.save")
@@ -270,10 +295,12 @@ class DialogFloorDetail extends LitElement {
if (this._addedAreas.has(areaId)) {
this._addedAreas.delete(areaId);
this._addedAreas = new Set(this._addedAreas);
this._updateDirtyState(this._currentState());
return;
}
this._removedAreas.add(areaId);
this._removedAreas = new Set(this._removedAreas);
this._updateDirtyState(this._currentState());
}
private _addArea(ev) {
@@ -285,15 +312,18 @@ class DialogFloorDetail extends LitElement {
if (this._removedAreas.has(areaId)) {
this._removedAreas.delete(areaId);
this._removedAreas = new Set(this._removedAreas);
this._updateDirtyState(this._currentState());
return;
}
this._addedAreas.add(areaId);
this._addedAreas = new Set(this._addedAreas);
this._updateDirtyState(this._currentState());
}
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._updateDirtyState(this._currentState());
}
private _levelChanged(ev: InputEvent) {
@@ -302,11 +332,13 @@ class DialogFloorDetail extends LitElement {
(ev.target as HaInput).value === ""
? null
: Number((ev.target as HaInput).value);
this._updateDirtyState(this._currentState());
}
private _iconChanged(ev) {
this._error = undefined;
this._icon = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private async _updateEntry() {
@@ -337,6 +369,7 @@ class DialogFloorDetail extends LitElement {
this._removedAreas
);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error =
@@ -349,6 +382,7 @@ class DialogFloorDetail extends LitElement {
private _aliasesChanged(ev: CustomEvent): void {
this._aliases = ev.detail.value;
this._updateDirtyState(this._currentState());
}
static get styles(): CSSResultGroup {
@@ -25,6 +25,7 @@ import type {
import { subscribeBackupEvents } from "../../../../data/backup_manager";
import { waitForIntegrationSetup } from "../../../../data/integration";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { RestoreBackupDialogParams } from "./show-dialog-restore-backup";
@@ -34,6 +35,10 @@ interface FormData {
custom_encryption_key: string;
}
interface RestoreBackupDirtyState {
userPassword: string;
}
const INITIAL_DATA: FormData = {
encryption_key_type: "config",
custom_encryption_key: "",
@@ -42,7 +47,10 @@ const INITIAL_DATA: FormData = {
const STEPS = ["confirm", "encryption", "progress"] as const;
@customElement("ha-dialog-restore-backup")
class DialogRestoreBackup extends LitElement implements HassDialog {
class DialogRestoreBackup
extends DirtyStateProviderMixin<RestoreBackupDirtyState>()(LitElement)
implements HassDialog
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _step?: "confirm" | "encryption" | "progress";
@@ -76,6 +84,7 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
this._error = undefined;
this._state = undefined;
this._stage = undefined;
this._initDirtyTracking({ type: "deep" }, { userPassword: "" });
const agentIds = Object.keys(this._params.backup.agents);
const preferedAgent = getPreferredAgentForDownload(agentIds);
@@ -139,6 +148,7 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
<ha-dialog
.open=${this._open}
header-title=${dialogTitle}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div class="content">
@@ -261,6 +271,7 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
private _passwordChanged(ev): void {
this._userPassword = ev.target.value;
this._updateDirtyState({ userPassword: this._userPassword || "" });
}
private async _restoreBackup() {
@@ -16,12 +16,20 @@ import "../../../components/input/ha-input";
import type { HaInput } from "../../../components/input/ha-input";
import type { BlueprintImportResult } from "../../../data/blueprint";
import { importBlueprint, saveBlueprint } from "../../../data/blueprint";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
interface BlueprintImportState {
value: string;
hasResult: boolean;
}
@customElement("ha-dialog-import-blueprint")
class DialogImportBlueprint extends LitElement {
class DialogImportBlueprint extends DirtyStateProviderMixin<BlueprintImportState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@@ -51,6 +59,10 @@ class DialogImportBlueprint extends LitElement {
this._sourceUrlWarning = !this._isTrustedBlueprintUrl(this._url);
this.large = false;
this._open = true;
this._initDirtyTracking(
{ type: "shallow" },
{ value: this._url ?? "", hasResult: false }
);
}
public closeDialog(): void {
@@ -75,6 +87,7 @@ class DialogImportBlueprint extends LitElement {
<ha-dialog
.open=${this._open}
width=${this.large ? "full" : "medium"}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-dialog-header slot="header">
@@ -138,6 +151,7 @@ class DialogImportBlueprint extends LitElement {
.label=${this.hass.localize(
"ui.panel.config.blueprint.add.file_name"
)}
@input=${this._inputChanged}
autofocus
></ha-input>
`}
@@ -191,6 +205,7 @@ class DialogImportBlueprint extends LitElement {
"ui.panel.config.blueprint.add.url"
)}
.value=${this._url || ""}
@input=${this._inputChanged}
autofocus
></ha-input>
`}
@@ -250,6 +265,13 @@ class DialogImportBlueprint extends LitElement {
});
}
private _inputChanged(ev: Event) {
this._updateDirtyState({
value: (ev.target as HaInput).value ?? "",
hasResult: !!this._result,
});
}
private async _import() {
this._url = undefined;
this._importing = true;
@@ -269,6 +291,10 @@ class DialogImportBlueprint extends LitElement {
!this._isTrustedBlueprintUrl(
this._result.blueprint.metadata.source_url
);
this._updateDirtyState({
value: this._result.suggested_filename || "",
hasResult: true,
});
} catch (err: any) {
this._error = err.message;
} finally {
@@ -313,6 +339,7 @@ class DialogImportBlueprint extends LitElement {
this._result!.exists
);
this._params.importedCallback();
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -7,13 +7,20 @@ import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import { updateEntityRegistryEntry } from "../../../data/entity/entity_registry";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "./ha-category-picker";
import type { AssignCategoryDialogParams } from "./show-dialog-assign-category";
interface AssignCategoryFormState {
category: string | undefined;
}
@customElement("dialog-assign-category")
class DialogAssignCategory extends LitElement {
class DialogAssignCategory extends DirtyStateProviderMixin<AssignCategoryFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _scope?: string;
@@ -34,6 +41,11 @@ class DialogAssignCategory extends LitElement {
this._category = params.entityReg.categories[params.scope];
this._error = undefined;
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
}
private _currentState(): AssignCategoryFormState {
return { category: this._category };
}
public closeDialog(): void {
@@ -57,6 +69,7 @@ class DialogAssignCategory extends LitElement {
header-title=${entry
? this.hass.localize("ui.panel.config.category.assign.edit")
: this.hass.localize("ui.panel.config.category.assign.assign")}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error
@@ -85,7 +98,7 @@ class DialogAssignCategory extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!!this._submitting}
.disabled=${!!this._submitting || !this.isDirtyState}
>
${this.hass.localize("ui.common.save")}
</ha-button>
@@ -99,6 +112,7 @@ class DialogAssignCategory extends LitElement {
this._category = undefined;
}
this._category = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private async _updateEntry() {
@@ -112,6 +126,7 @@ class DialogAssignCategory extends LitElement {
categories: { [this._scope!]: this._category || null },
}
);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error =
@@ -26,6 +26,7 @@ import type {
NumberSelector,
} from "../../../../data/selector";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
@@ -36,8 +37,14 @@ interface CombinedStat {
fiveMin: StatisticValue[];
}
interface AdjustState {
amount: number | undefined;
}
@customElement("dialog-statistics-adjust-sum")
export class DialogStatisticsFixUnsupportedUnitMetadata extends LitElement {
export class DialogStatisticsFixUnsupportedUnitMetadata extends DirtyStateProviderMixin<AdjustState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DialogStatisticsAdjustSumParams;
@@ -145,7 +152,7 @@ export class DialogStatisticsFixUnsupportedUnitMetadata extends LitElement {
>
<ha-button
slot="primaryAction"
.disabled=${this._busy}
.disabled=${this._busy || !this.isDirtyState}
@click=${this._fixIssue}
>
${this.hass.localize(
@@ -161,6 +168,7 @@ export class DialogStatisticsFixUnsupportedUnitMetadata extends LitElement {
header-title=${this.hass.localize(
"ui.panel.config.developer-tools.tabs.statistics.fix_issue.adjust_sum.title"
)}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${content}
@@ -252,6 +260,7 @@ export class DialogStatisticsFixUnsupportedUnitMetadata extends LitElement {
private _clearChosenStatistic() {
this._chosenStat = undefined;
this._initDirtyTracking({ type: "deep" }, { amount: undefined });
}
private _setChosenStatistic(ev) {
@@ -262,6 +271,7 @@ export class DialogStatisticsFixUnsupportedUnitMetadata extends LitElement {
this._chosenStat = stat;
this._origAmount = growth;
this._amount = growth;
this._initDirtyTracking({ type: "deep" }, { amount: this._origAmount });
}
private _dateTimeSelectorChanged(ev) {
@@ -330,6 +340,7 @@ export class DialogStatisticsFixUnsupportedUnitMetadata extends LitElement {
private _amountChanged(ev) {
this._amount = ev.detail.value;
this._updateDirtyState({ amount: this._amount });
}
private async _fetchStats(): Promise<void> {
@@ -506,6 +517,7 @@ export class DialogStatisticsFixUnsupportedUnitMetadata extends LitElement {
"ui.panel.config.developer-tools.tabs.statistics.fix_issue.adjust_sum.sum_adjusted"
),
});
this._markDirtyStateClean();
this.closeDialog();
}
@@ -13,12 +13,22 @@ import type { HaSwitch } from "../../../../components/ha-switch";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { DeviceRegistryDetailDialogParams } from "./show-dialog-device-registry-detail";
interface DeviceFormState {
nameByUser: string;
areaId: string;
labels: string[];
disabledBy: DeviceRegistryEntry["disabled_by"];
}
@customElement("dialog-device-registry-detail")
class DialogDeviceRegistryDetail extends LitElement {
class DialogDeviceRegistryDetail extends DirtyStateProviderMixin<DeviceFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -47,9 +57,19 @@ class DialogDeviceRegistryDetail extends LitElement {
this._labels = this._params.device.labels || [];
this._disabledBy = this._params.device.disabled_by;
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
await this.updateComplete;
}
private _currentState(): DeviceFormState {
return {
nameByUser: this._nameByUser,
areaId: this._areaId,
labels: this._labels,
disabledBy: this._disabledBy,
};
}
public closeDialog(): void {
this._open = false;
}
@@ -73,7 +93,7 @@ class DialogDeviceRegistryDetail extends LitElement {
this.hass.localize,
this.hass.states
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div>
@@ -157,7 +177,7 @@ class DialogDeviceRegistryDetail extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize("ui.dialogs.device-registry-detail.update")}
</ha-button>
@@ -169,18 +189,22 @@ class DialogDeviceRegistryDetail extends LitElement {
private _nameChanged(ev: InputEvent): void {
this._error = undefined;
this._nameByUser = (ev.target as HaInput).value ?? "";
this._updateDirtyState(this._currentState());
}
private _areaPicked(event: CustomEvent): void {
this._areaId = event.detail.value;
this._updateDirtyState(this._currentState());
}
private _labelsChanged(event: CustomEvent): void {
this._labels = event.detail.value;
this._updateDirtyState(this._currentState());
}
private _disabledByChanged(ev: Event): void {
this._disabledBy = (ev.target as HaSwitch).checked ? null : "user";
this._updateDirtyState(this._currentState());
}
private async _updateEntry(): Promise<void> {
@@ -192,6 +216,7 @@ class DialogDeviceRegistryDetail extends LitElement {
labels: this._labels || null,
disabled_by: this._disabledBy || null,
});
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error =
@@ -27,13 +27,18 @@ import {
updateEntityRegistryEntry,
} from "../../../../data/entity/entity_registry";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { VacuumSegmentMappingDialogParams } from "./show-dialog-vacuum-segment-mapping";
interface VacuumSegmentMappingState {
areaMapping: Record<string, string[]>;
}
@customElement("dialog-vacuum-segment-mapping")
export class DialogVacuumSegmentMapping
extends LitElement
extends DirtyStateProviderMixin<VacuumSegmentMappingState>()(LitElement)
implements HassDialog<VacuumSegmentMappingDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -77,6 +82,10 @@ export class DialogVacuumSegmentMapping
} else {
this._areaMapping = {};
}
this._initDirtyTracking(
{ type: "deep" },
{ areaMapping: this._areaMapping }
);
}
private _dialogClosed(): void {
@@ -86,6 +95,7 @@ export class DialogVacuumSegmentMapping
private _valueChanged(ev: CustomEvent) {
this._areaMapping = ev.detail.value;
this._updateDirtyState({ areaMapping: ev.detail.value });
}
private async _save() {
@@ -107,6 +117,7 @@ export class DialogVacuumSegmentMapping
options: options,
});
this._markDirtyStateClean();
this.closeDialog();
} catch (_err: any) {
// Error will be shown by the system
@@ -159,6 +170,7 @@ export class DialogVacuumSegmentMapping
<ha-dialog
.open=${this._open}
@closed=${this._dialogClosed}
.preventScrimClose=${this.isDirtyState}
.headerTitle=${this.hass.localize(
"ui.dialogs.vacuum_segment_mapping.title"
)}
@@ -189,7 +201,7 @@ export class DialogVacuumSegmentMapping
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize("ui.common.save")}
</ha-button>
@@ -7,6 +7,7 @@ import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dialog";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-button";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type {
@@ -16,7 +17,9 @@ import type {
import type { SchemaUnion } from "../../../../components/ha-form/types";
@customElement("dialog-schedule-block-info")
class DialogScheduleBlockInfo extends LitElement {
class DialogScheduleBlockInfo extends DirtyStateProviderMixin<ScheduleBlockInfo>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: Record<string, string>;
@@ -60,6 +63,7 @@ class DialogScheduleBlockInfo extends LitElement {
this._error = undefined;
this._data = params.block;
this._expand = !!params.block?.data;
this._initDirtyTracking({ type: "deep" }, this._data);
this._open = true;
}
@@ -84,6 +88,7 @@ class DialogScheduleBlockInfo extends LitElement {
header-title=${this.hass!.localize(
"ui.dialogs.helper_settings.schedule.edit_schedule_block"
)}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div>
@@ -106,7 +111,11 @@ class DialogScheduleBlockInfo extends LitElement {
>
${this.hass!.localize("ui.common.delete")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._updateBlock}>
<ha-button
slot="primaryAction"
@click=${this._updateBlock}
.disabled=${!this.isDirtyState}
>
${this.hass!.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
@@ -117,6 +126,7 @@ class DialogScheduleBlockInfo extends LitElement {
private _valueChanged(ev: CustomEvent) {
this._error = undefined;
this._data = ev.detail.value;
this._updateDirtyState(ev.detail.value);
}
private _updateBlock() {
@@ -15,6 +15,7 @@ import {
setMatterLockCredential,
setMatterLockUser,
} from "../../../../../data/matter-lock";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { MatterLockUserEditDialogParams } from "./show-dialog-matter-lock-user-edit";
@@ -24,8 +25,16 @@ const SIMPLE_USER_TYPES: MatterLockUserType[] = [
"disposable_user",
];
interface MatterLockFormState {
userName: string;
userType: MatterLockUserType;
pinCode: string;
}
@customElement("dialog-matter-lock-user-edit")
class DialogMatterLockUserEdit extends LitElement {
class DialogMatterLockUserEdit extends DirtyStateProviderMixin<MatterLockFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: MatterLockUserEditDialogParams;
@@ -57,6 +66,15 @@ class DialogMatterLockUserEdit extends LitElement {
this._userName = "";
this._userType = "unrestricted_user";
}
this._initDirtyTracking({ type: "deep" }, this._currentState());
}
private _currentState(): MatterLockFormState {
return {
userName: this._userName,
userType: this._userType,
pinCode: this._pinCode,
};
}
protected render() {
@@ -78,6 +96,7 @@ class DialogMatterLockUserEdit extends LitElement {
<ha-dialog
.open=${this._open}
header-title=${title}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div class="form">
@@ -149,7 +168,9 @@ class DialogMatterLockUserEdit extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._saving || (isNew && !supportsPinCredential)}
.disabled=${this._saving ||
(isNew && !supportsPinCredential) ||
(!isNew && !this.isDirtyState)}
>
${this._saving
? html`<ha-spinner size="small"></ha-spinner>`
@@ -164,12 +185,14 @@ class DialogMatterLockUserEdit extends LitElement {
private _handleNameChange(ev: InputEvent): void {
this._userName = (ev.target as HTMLInputElement).value;
this._updateDirtyState(this._currentState());
}
private _handlePinChange(ev: InputEvent): void {
const value = (ev.target as HTMLInputElement).value.replace(/\D/g, "");
this._pinCode = value;
(ev.target as HTMLInputElement).value = value;
this._updateDirtyState(this._currentState());
}
private get _userTypeOptions(): SelectBoxOption[] {
@@ -186,6 +209,7 @@ class DialogMatterLockUserEdit extends LitElement {
private _handleUserTypeChanged(ev: CustomEvent): void {
this._userType = ev.detail.value as MatterLockUserType;
this._updateDirtyState(this._currentState());
}
private async _save(): Promise<void> {
@@ -258,6 +282,7 @@ class DialogMatterLockUserEdit extends LitElement {
}
this._params.onSaved();
this._markDirtyStateClean();
this.closeDialog();
} catch (err: unknown) {
this._error =
@@ -20,13 +20,18 @@ import {
fetchGroupableDevices,
} from "../../../../../data/zha";
import type { HassDialog } from "../../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyleScrollbar } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { ZHAAddGroupMembersDialogParams } from "./show-dialog-zha-add-group-members";
interface AddMembersFormState {
selected: string[];
}
@customElement("dialog-zha-add-group-members")
class DialogZHAAddGroupMembers
extends LitElement
extends DirtyStateProviderMixin<AddMembersFormState>()(LitElement)
implements HassDialog<ZHAAddGroupMembersDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -56,6 +61,7 @@ class DialogZHAAddGroupMembers
this._group = undefined;
this._selectedDevicesToAdd = [];
this._open = true;
this._initDirtyTracking({ type: "deep" }, { selected: [] });
this._fetchData();
}
@@ -96,7 +102,7 @@ class DialogZHAAddGroupMembers
header-title=${this.hass.localize(
"ui.panel.config.zha.groups.add_members"
)}
?prevent-scrim-close=${this._selectedDevicesToAdd.length > 0}
?prevent-scrim-close=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-icon-button
@@ -158,7 +164,7 @@ class DialogZHAAddGroupMembers
<ha-button
slot="primaryAction"
.disabled=${this._loading ||
!this._selectedDevicesToAdd.length ||
!this.isDirtyState ||
this._processingAdd}
.loading=${this._processingAdd}
@click=${this._addMembersToGroup}
@@ -302,6 +308,7 @@ class DialogZHAAddGroupMembers
}
this._selectedDevicesToAdd = selectedDevicesToAdd;
this._updateDirtyState({ selected: this._selectedDevicesToAdd });
}
private _handleDeselected(ev: CustomEvent<number>): void {
@@ -316,6 +323,7 @@ class DialogZHAAddGroupMembers
);
}
this._selectedDevicesToAdd = selectedDevicesToAdd;
this._updateDirtyState({ selected: this._selectedDevicesToAdd });
}
private async _addMembersToGroup(): Promise<void> {
@@ -332,6 +340,7 @@ class DialogZHAAddGroupMembers
);
this._params!.devicesAddedCallback(group);
this._processingAdd = false;
this._markDirtyStateClean();
this.closeDialog();
} finally {
this._processingAdd = false;
@@ -12,6 +12,7 @@ import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import { changeZHANetworkChannel } from "../../../../../data/zha";
import type { HassDialog } from "../../../../../dialogs/make-dialog-manager";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import type { HomeAssistant } from "../../../../../types";
import type { ZHAChangeChannelDialogParams } from "./show-dialog-zha-change-channel";
@@ -35,9 +36,13 @@ const VALID_CHANNELS = [
26,
];
interface ChangeChannelFormState {
newChannel: "auto" | number;
}
@customElement("dialog-zha-change-channel")
class DialogZHAChangeChannel
extends LitElement
extends DirtyStateProviderMixin<ChangeChannelFormState>()(LitElement)
implements HassDialog<ZHAChangeChannelDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -54,6 +59,7 @@ class DialogZHAChangeChannel
this._params = params;
this._newChannel = "auto";
this._open = true;
this._initDirtyTracking({ type: "shallow" }, { newChannel: "auto" });
}
public closeDialog(): boolean {
@@ -82,7 +88,7 @@ class DialogZHAChangeChannel
header-title=${this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.title"
)}
prevent-scrim-close
?prevent-scrim-close=${this.isDirtyState || this._migrationInProgress}
@closed=${this._dialogClosed}
>
<ha-alert
@@ -154,6 +160,7 @@ class DialogZHAChangeChannel
private _newChannelChosen(ev: HaSelectSelectEvent): void {
const value = ev.detail.value;
this._newChannel = value === "auto" ? "auto" : parseInt(value, 10);
this._updateDirtyState({ newChannel: this._newChannel });
}
private async _changeNetworkChannel(): Promise<void> {
@@ -173,6 +180,7 @@ class DialogZHAChangeChannel
),
});
this._markDirtyStateClean();
this.closeDialog();
}
}
@@ -30,12 +30,22 @@ import type {
ZwaveCredential,
ZwaveCredentialType,
} from "../../../../../data/zwave_js-credentials";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { ZwaveCredentialUserEditDialogParams } from "./show-dialog-zwave_js-credential-user-edit";
interface CredentialFormState {
userName: string;
userType: string;
credentialType: ZwaveCredentialType | "";
credentialData: string;
}
@customElement("dialog-zwave_js-credential-user-edit")
class DialogZwaveCredentialUserEdit extends LitElement {
class DialogZwaveCredentialUserEdit extends DirtyStateProviderMixin<CredentialFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: ZwaveCredentialUserEditDialogParams;
@@ -56,10 +66,6 @@ class DialogZwaveCredentialUserEdit extends LitElement {
@state() private _open = false;
private _initialUserName = "";
private _initialUserType = "";
public async showDialog(
params: ZwaveCredentialUserEditDialogParams
): Promise<void> {
@@ -88,8 +94,16 @@ class DialogZwaveCredentialUserEdit extends LitElement {
// Always show an empty field for credential data - we override it when something is entered.
this._credentialData = "";
this._initialUserName = this._userName;
this._initialUserType = this._userType;
this._initDirtyTracking({ type: "deep" }, this._currentState());
}
private _currentState(): CredentialFormState {
return {
userName: this._userName,
userType: this._userType,
credentialType: this._credentialType,
credentialData: this._credentialData,
};
}
// Credentials with non-enterable types (e.g. biometric) can't be edited
@@ -185,6 +199,7 @@ class DialogZwaveCredentialUserEdit extends LitElement {
.hass=${this.hass}
.open=${this._open}
header-title=${title}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div class="form">
@@ -335,6 +350,7 @@ class DialogZwaveCredentialUserEdit extends LitElement {
private _handleNameChange(ev: InputEvent): void {
this._userName = (ev.target as HTMLInputElement).value;
this._updateDirtyState(this._currentState());
}
private _handleCredentialDataChange(ev: InputEvent): void {
@@ -347,6 +363,7 @@ class DialogZwaveCredentialUserEdit extends LitElement {
}
}
this._credentialData = value;
this._updateDirtyState(this._currentState());
}
private _handleCredentialBeforeInput(ev: InputEvent): void {
@@ -388,14 +405,7 @@ class DialogZwaveCredentialUserEdit extends LitElement {
return !!this._credentialData;
}
// Otherwise allow saving when the user was renamed or new credential data was entered
return !!this._credentialData || this._userChanged;
}
private get _userChanged(): boolean {
return (
this._userName !== this._initialUserName ||
this._userType !== this._initialUserType
);
return this.isDirtyState;
}
private get _credentialTypeChanged(): boolean {
@@ -459,10 +469,12 @@ class DialogZwaveCredentialUserEdit extends LitElement {
// Changing the credential type requires entering new credentials.
// To make this obvious, we mark the field as dirty, so a validation error is shown.
this._credentialDataDirty = this._credentialTypeChanged;
this._updateDirtyState(this._currentState());
}
private _handleUserTypeChanged(ev: CustomEvent): void {
this._userType = ev.detail.value as string;
this._updateDirtyState(this._currentState());
}
private async _save(): Promise<void> {
@@ -543,6 +555,7 @@ class DialogZwaveCredentialUserEdit extends LitElement {
}
params.onSaved();
this._markDirtyStateClean();
this.closeDialog();
}
@@ -553,7 +566,11 @@ class DialogZwaveCredentialUserEdit extends LitElement {
const user = params.user!;
const existingCred = this._existingCredential(params);
if (this._userChanged) {
const userChanged =
this._userName !== (user.user_name || "") ||
this._userType !== user.user_type;
if (userChanged) {
await setZwaveUser(this.hass, params.entity_id, {
user_id: user.user_id,
user_name: this._supportsUserNames ? this._userName.trim() : undefined,
@@ -581,6 +598,7 @@ class DialogZwaveCredentialUserEdit extends LitElement {
}
params.onSaved();
this._markDirtyStateClean();
this.closeDialog();
}
@@ -36,6 +36,7 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { ZWaveJSUpdateFirmwareNodeDialogParams } from "./show-dialog-zwave_js-update-firmware-node";
@@ -48,8 +49,14 @@ const firmwareTargetSchema: HaFormSchema[] = [
},
];
interface FirmwareFormState {
file?: File;
}
@customElement("dialog-zwave_js-update-firmware-node")
class DialogZWaveJSUpdateFirmwareNode extends LitElement {
class DialogZWaveJSUpdateFirmwareNode extends DirtyStateProviderMixin<FirmwareFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private device?: DeviceRegistryEntry;
@@ -90,6 +97,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
);
this.device = params.device;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, { file: undefined });
this._fetchData();
this._subscribeNodeStatus();
}
@@ -199,6 +207,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
header-title=${this.hass.localize(
"ui.panel.config.zwave_js.update_firmware.title"
)}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${!this._updateProgressMessage && !this._updateFinishedMessage
@@ -356,6 +365,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
private async _beginFirmwareUpdate(): Promise<void> {
this._uploading = true;
this._markDirtyStateClean();
this._updateProgressMessage = this._updateFinishedMessage = undefined;
try {
this._subscribeNodeFirmwareUpdate();
@@ -422,6 +432,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
this._updateProgressMessage = undefined;
this._updateInProgress = false;
this._uploading = false;
this._initDirtyTracking({ type: "shallow" }, { file: undefined });
}
}
@@ -487,6 +498,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
private async _uploadFile(ev) {
this._firmwareFile = ev.detail.files[0];
this._updateDirtyState({ file: this._firmwareFile });
}
static get styles(): CSSResultGroup {
@@ -14,12 +14,20 @@ import "../../../components/input/ha-input";
import { internationalizationContext } from "../../../data/context";
import type { LabelRegistryEntryMutableParams } from "../../../data/label/label_registry";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { LabelDetailDialogParams } from "./show-dialog-label-detail";
interface LabelFormState {
name: string;
icon: string;
color: string;
description: string;
}
@customElement("dialog-label-detail")
class DialogLabelDetail extends DialogMixin<LabelDetailDialogParams>(
LitElement
class DialogLabelDetail extends DirtyStateProviderMixin<LabelFormState>()(
DialogMixin<LabelDetailDialogParams>(LitElement)
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
@@ -50,6 +58,16 @@ class DialogLabelDetail extends DialogMixin<LabelDetailDialogParams>(
this._color = "";
this._description = "";
}
this._initDirtyTracking({ type: "deep" }, this._currentState());
}
private _currentState(): LabelFormState {
return {
name: this._name,
icon: this._icon,
color: this._color,
description: this._description,
};
}
protected render() {
@@ -63,7 +81,7 @@ class DialogLabelDetail extends DialogMixin<LabelDetailDialogParams>(
header-title=${this.params.entry
? this.params.entry.name || this.params.entry.label_id
: this._i18n.localize("ui.dialogs.label-detail.new_label")}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
>
<div>
${this._error
@@ -129,7 +147,9 @@ class DialogLabelDetail extends DialogMixin<LabelDetailDialogParams>(
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting || !this._name}
.disabled=${this._submitting ||
!this._name ||
(!!this.params.entry && !this.isDirtyState)}
>
${this.params.entry
? this._i18n.localize("ui.common.update")
@@ -146,6 +166,7 @@ class DialogLabelDetail extends DialogMixin<LabelDetailDialogParams>(
this._error = undefined;
this[`_${configValue}`] = target.value;
this._updateDirtyState(this._currentState());
}
private _valueChanged(ev: CustomEvent) {
@@ -154,6 +175,7 @@ class DialogLabelDetail extends DialogMixin<LabelDetailDialogParams>(
this._error = undefined;
this[`_${configValue}`] = ev.detail.value || "";
this._updateDirtyState(this._currentState());
}
private async _updateEntry() {
@@ -170,6 +192,7 @@ class DialogLabelDetail extends DialogMixin<LabelDetailDialogParams>(
} else {
await this.params!.createEntry!(values);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err ? err.message : "Unknown error";
@@ -22,6 +22,7 @@ import {
SupervisorMountUsage,
updateSupervisorMount,
} from "../../../data/supervisor/mounts";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
@@ -152,7 +153,9 @@ const mountSchema = memoizeOne(
);
@customElement("dialog-mount-view")
class ViewMountDialog extends LitElement {
class ViewMountDialog extends DirtyStateProviderMixin<
Partial<SupervisorMountRequestParams>
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _data?: SupervisorMountRequestParams;
@@ -187,6 +190,7 @@ class ViewMountDialog extends LitElement {
) {
this._showCIFSVersion = true;
}
this._initDirtyTracking({ type: "deep" }, this._data ?? {});
}
public closeDialog(): void {
@@ -219,7 +223,7 @@ class ViewMountDialog extends LitElement {
: this.hass.localize(
"ui.panel.config.storage.network_mounts.add_title"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<a
@@ -280,6 +284,7 @@ class ViewMountDialog extends LitElement {
<ha-progress-button
slot="primaryAction"
.progress=${!!this._waiting}
.disabled=${!this.isDirtyState}
@click=${this._connectMount}
>
${this._existing
@@ -340,6 +345,7 @@ class ViewMountDialog extends LitElement {
) {
this._validationWarning.version = "not_recomeded_cifs_version";
}
this._updateDirtyState(this._data ?? {});
}
private async _connectMount(ev) {
@@ -368,6 +374,7 @@ class ViewMountDialog extends LitElement {
if (this._reloadMounts) {
this._reloadMounts();
}
this._markDirtyStateClean();
this.closeDialog();
}
@@ -24,11 +24,16 @@ import {
moveDatadisk,
} from "../../../data/hassio/host";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { bytesToString } from "../../../util/bytes-to-string";
import type { MoveDatadiskDialogParams } from "./show-dialog-move-datadisk";
interface MoveDatadiskFormState {
selectedDevice: string | undefined;
}
const calculateMoveTime = memoizeOne((hostInfo: HassioHostInfo): number => {
// Assume a speed of 30 MB/s.
const moveTime = (hostInfo.disk_used * 1000) / 60 / 30;
@@ -37,7 +42,9 @@ const calculateMoveTime = memoizeOne((hostInfo: HassioHostInfo): number => {
});
@customElement("dialog-move-datadisk")
class MoveDatadiskDialog extends LitElement {
class MoveDatadiskDialog extends DirtyStateProviderMixin<MoveDatadiskFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _hostInfo?: HassioHostInfo;
@@ -57,6 +64,12 @@ class MoveDatadiskDialog extends LitElement {
): Promise<Promise<void>> {
this._hostInfo = dialogParams.hostInfo;
this._open = true;
this._initDirtyTracking(
{ type: "deep" },
{
selectedDevice: this._selectedDevice,
}
);
try {
this._osInfo = await fetchHassioHassOsInfo(this.hass);
@@ -113,6 +126,7 @@ class MoveDatadiskDialog extends LitElement {
header-title=${this._moving
? this.hass.localize("ui.panel.config.storage.datadisk.moving")
: this.hass.localize("ui.panel.config.storage.datadisk.title")}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._moving
@@ -181,6 +195,7 @@ class MoveDatadiskDialog extends LitElement {
private _selectDevice(ev: HaSelectSelectEvent): void {
this._selectedDevice = ev.detail.value;
this._updateDirtyState({ selectedDevice: this._selectedDevice });
}
private async _moveDatadisk() {
+24 -3
View File
@@ -16,15 +16,22 @@ import "../../../components/input/ha-input";
import type { HaInput } from "../../../components/input/ha-input";
import type { Tag, UpdateTagParams } from "../../../data/tag";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import type { TagDetailDialogParams } from "./show-dialog-tag-detail";
interface TagFormState {
name: string;
id: string | undefined;
useCustomId: boolean;
}
@customElement("dialog-tag-detail")
class DialogTagDetail
extends LitElement
extends DirtyStateProviderMixin<TagFormState>()(LitElement)
implements HassDialog<TagDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -56,6 +63,7 @@ class DialogTagDetail
this._id = "";
this._name = "";
}
this._initDirtyTracking({ type: "deep" }, this._currentState());
// Defer QR until dialog has had a chance to apply styles
requestAnimationFrame(() => {
@@ -63,6 +71,14 @@ class DialogTagDetail
});
}
private _currentState(): TagFormState {
return {
name: this._name,
id: this._id,
useCustomId: this._useCustomId,
};
}
public closeDialog(): boolean {
this._open = false;
return true;
@@ -85,7 +101,7 @@ class DialogTagDetail
header-title=${this._params.entry
? this.hass!.localize("ui.panel.config.tag.detail.tag_details")
: this.hass!.localize("ui.panel.config.tag.detail.new_tag")}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div>
@@ -194,7 +210,9 @@ class DialogTagDetail
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting || !this._name}
.disabled=${this._submitting ||
!this._name ||
(!!this._params.entry && !this.isDirtyState)}
>
${this._params.entry
? this.hass!.localize("ui.panel.config.tag.detail.update")
@@ -222,6 +240,7 @@ class DialogTagDetail
this._error = undefined;
this[`_${configValue}`] = target.value;
this._updateDirtyState(this._currentState());
}
private _useCustomIdChanged(ev: CustomEvent) {
@@ -233,6 +252,7 @@ class DialogTagDetail
} else {
this._id = "";
}
this._updateDirtyState(this._currentState());
}
private async _copyId() {
@@ -260,6 +280,7 @@ class DialogTagDetail
this._useCustomId ? this._id : ""
);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err ? err.message : "Unknown error";
@@ -26,6 +26,7 @@ import {
import type { ExposeEntitySettings } from "../../../data/expose";
import { voiceAssistants } from "../../../data/expose";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleScrollbar } from "../../../resources/styles";
import { loadVirtualizer } from "../../../resources/virtualizer";
import "./entity-voice-settings";
@@ -37,8 +38,8 @@ interface FilteredEntity {
}
@customElement("dialog-expose-entity")
class DialogExposeEntity extends DialogMixin<ExposeEntityDialogParams>(
LitElement
class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
DialogMixin<ExposeEntityDialogParams>(LitElement)
) {
@state() private _filter?: string;
@@ -46,6 +47,11 @@ class DialogExposeEntity extends DialogMixin<ExposeEntityDialogParams>(
@state() private _dialogReady = false;
public connectedCallback(): void {
super.connectedCallback();
this._initDirtyTracking({ type: "deep" }, this._selected);
}
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n!: ContextType<typeof internationalizationContext>;
@@ -87,7 +93,7 @@ class DialogExposeEntity extends DialogMixin<ExposeEntityDialogParams>(
open
header-title=${header}
header-subtitle=${subtitle}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@after-show=${this._loadVirtualizer}
>
<ha-input-search
@@ -149,6 +155,7 @@ class DialogExposeEntity extends DialogMixin<ExposeEntityDialogParams>(
} else {
this._selected = this._selected.filter((item) => item !== entityId);
}
this._updateDirtyState(this._selected);
};
private _handleItemKeydown(ev: KeyboardEvent) {
@@ -272,6 +279,7 @@ class DialogExposeEntity extends DialogMixin<ExposeEntityDialogParams>(
private _expose() {
this.params!.exposeEntities(this._selected);
this._markDirtyStateClean();
this.closeDialog();
}
@@ -304,38 +304,34 @@ function formatTooltip(
: nothing}`;
}
function getDatapointX(datapoint: NonNullable<LineSeriesOption["data"]>[0]) {
const item =
datapoint && typeof datapoint === "object" && "value" in datapoint
? datapoint
: { value: datapoint };
return Number(item.value?.[0]);
}
export function fillLineGaps(datasets: LineSeriesOption[]) {
const buckets = Array.from(
new Set(
datasets
.map((dataset) =>
dataset.data!.map((datapoint) => getDatapointX(datapoint))
)
.flat()
)
).sort((a, b) => a - b);
// Single pass per datapoint: normalise it to a LineDataItemOption, compute
// its x once, collect every x into the shared bucket set, and build each
// dataset's lookup map at the same time. This avoids re-deriving x and
// re-discriminating the tuple/object shape in a separate pass.
const bucketSet = new Set<number>();
const dataMaps: Map<number, LineDataItemOption>[] = [];
datasets.forEach((dataset) => {
for (const dataset of datasets) {
const dataMap = new Map<number, LineDataItemOption>();
dataset.data!.forEach((datapoint) => {
for (const datapoint of dataset.data!) {
const item: LineDataItemOption =
datapoint && typeof datapoint === "object" && "value" in datapoint
? datapoint
: ({ value: datapoint } as LineDataItemOption);
const x = getDatapointX(datapoint);
const x = Number(item.value?.[0]);
bucketSet.add(x);
if (!Number.isNaN(x)) {
dataMap.set(x, item);
}
});
}
dataMaps.push(dataMap);
}
const buckets = Array.from(bucketSet).sort((a, b) => a - b);
datasets.forEach((dataset, index) => {
const dataMap = dataMaps[index];
dataset.data = buckets.map((bucket) => dataMap.get(bucket) ?? [bucket, 0]);
});
@@ -0,0 +1,241 @@
import type { BarSeriesOption } from "echarts/charts";
import { computeYAxisFractionDigits } from "../../../../components/chart/y-axis-fraction-digits";
import { fillDataGapsAndRoundCaps } from "../../../../components/chart/round-caps";
import type {
EnergyData,
GasSourceTypeEnergyPreference,
} from "../../../../data/energy";
import { getSuggestedPeriod } from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import type { HomeAssistant } from "../../../../types";
import { getEnergyColor } from "./common/color";
import {
type EnergyDataPoint,
getCompareTransform,
} from "./common/energy-chart-options";
export interface EnergyGasGraphDataParams {
hass: HomeAssistant;
energyData: EnergyData;
computedStyles: CSSStyleDeclaration;
/** Current time, injected so the transform is deterministic. */
now: Date;
}
export interface EnergyGasGraphData {
chartData: BarSeriesOption[];
start: Date;
end: Date;
compareStart?: Date;
compareEnd?: Date;
unit?: string;
total?: number;
yAxisFractionDigits: number;
}
/**
* Transforms an energy collection update (`EnergyData` + config + environment)
* into the gas graph card's chart series and derived state. Pure data
* processing: every environment read (current time, theme style, hass) is
* injected so the transform is deterministic and benchmarkable.
*/
export function generateEnergyGasGraphData(
params: EnergyGasGraphDataParams
): EnergyGasGraphData {
const { hass, energyData, computedStyles, now } = params;
const start = energyData.start;
const end = energyData.end || now;
const compareStart = energyData.startCompare;
const compareEnd = energyData.endCompare;
const gasSources: GasSourceTypeEnergyPreference[] =
energyData.prefs.energy_sources.filter(
(source) => source.type === "gas"
) as GasSourceTypeEnergyPreference[];
const unit = energyData.gasUnit;
const datasets: BarSeriesOption[] = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number) => {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
};
// `compareTransform` and `period` depend only on start/end/compareStart,
// which are identical for both the compare and main passes. Compute them
// once here instead of recomputing (and re-allocating the transform
// closure) inside each processDataSet call.
const compareTransform = getCompareTransform(start, compareStart!);
const period = getSuggestedPeriod(start, end);
if (energyData.statsCompare) {
datasets.push(
...processDataSet(
hass,
compareTransform,
period,
energyData.statsCompare,
energyData.statsMetadata,
gasSources,
computedStyles,
trackY,
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: gas` so it doesn't take up space yet
const firstId = gasSources[0]?.stat_energy_from ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "gas",
data: [],
});
}
datasets.push(
...processDataSet(
hass,
compareTransform,
period,
energyData.stats,
energyData.statsMetadata,
gasSources,
computedStyles,
trackY
)
);
fillDataGapsAndRoundCaps(datasets);
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
const chartData = datasets;
const total = processTotal(energyData.stats, gasSources);
return {
chartData,
start,
end,
compareStart,
compareEnd,
unit,
total,
yAxisFractionDigits,
};
}
function processTotal(
statistics: Statistics,
gasSources: GasSourceTypeEnergyPreference[]
) {
return gasSources.reduce(
(sum, source) =>
sum +
(source.stat_energy_from in statistics
? statistics[source.stat_energy_from].reduce(
(acc, curr) => acc + (curr.change || 0),
0
)
: 0),
0
);
}
function processDataSet(
hass: HomeAssistant,
compareTransform: (ts: Date) => Date,
period: ReturnType<typeof getSuggestedPeriod>,
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
gasSources: GasSourceTypeEnergyPreference[],
computedStyles: CSSStyleDeclaration,
trackY: (v: number) => void,
compare = false
) {
const data: BarSeriesOption[] = [];
// `center` (sub-daily midpoint) and the active compare transform depend only
// on the call-level `period`/`compare` args, so they are loop-invariant.
// Hoist them once and inline computeStatMidpoint below, choosing the branch
// from these two booleans, to avoid a per-point function call, a per-point
// `center` recompute and a per-point `compare ? … : undefined` ternary in the
// hottest loop. The arithmetic and addition order are kept identical so the
// resulting timestamps are bit-identical to computeStatMidpoint.
const center = period === "hour" || period === "5minute";
const transform = compare ? compareTransform : undefined;
gasSources.forEach((source, idx) => {
const statId = source.stat_energy_from;
let prevStart: number | null = null;
const gasConsumptionData: BarSeriesOption["data"] = [];
// Process gas consumption data.
if (statId in statistics) {
const stats = statistics[statId];
for (const point of stats) {
const change = point.change;
if (change === null || change === undefined || change === 0) {
continue;
}
const pointStart = point.start;
if (prevStart === pointStart) {
continue;
}
let midpoint: number;
if (center) {
midpoint = transform
? (transform(new Date(pointStart)).getTime() +
transform(new Date(point.end)).getTime()) /
2
: (pointStart + point.end) / 2;
} else {
midpoint = transform
? transform(new Date(pointStart)).getTime()
: pointStart;
}
const dataPoint: EnergyDataPoint = [midpoint, change, pointStart];
gasConsumptionData.push(dataPoint);
trackY(change);
prevStart = pointStart;
}
}
data.push({
type: "bar",
cursor: "default",
id: compare ? "compare-" + statId : statId,
name:
source.name ||
getStatisticLabel(hass, statId, statisticsMetaData[statId]),
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
computedStyles,
hass.themes.darkMode,
false,
compare,
"--energy-gas-color",
idx
),
},
color: getEnergyColor(
computedStyles,
hass.themes.darkMode,
true,
compare,
"--energy-gas-color",
idx
),
data: gasConsumptionData,
stack: compare ? "compare-gas" : "gas",
});
});
return data;
}
@@ -0,0 +1,375 @@
import type { BarSeriesOption, LineSeriesOption } from "echarts/charts";
import { computeYAxisFractionDigits } from "../../../../components/chart/y-axis-fraction-digits";
import type {
EnergyData,
EnergySolarForecasts,
SolarSourceTypeEnergyPreference,
} from "../../../../data/energy";
import { getSuggestedPeriod } from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import type { HomeAssistant } from "../../../../types";
import { getEnergyColor } from "./common/color";
import {
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCompareTransform,
} from "./common/energy-chart-options";
export interface EnergySolarGraphDataParams {
hass: HomeAssistant;
energyData: EnergyData;
/**
* Solar forecasts resolved by the caller (the async fetch stays in the
* component); `undefined` when no forecast data is available.
*/
forecasts: EnergySolarForecasts | undefined;
computedStyles: CSSStyleDeclaration;
/** Fallback for `energyData.end` (the component uses `endOfToday()`). */
now: Date;
}
export interface EnergySolarGraphData {
chartData: (BarSeriesOption | LineSeriesOption)[];
total: number;
start: Date;
end: Date;
compareStart?: Date;
compareEnd?: Date;
yAxisFractionDigits: number;
}
/**
* Transforms an `EnergyData` collection update into the ECharts series and
* derived state for `hui-energy-solar-graph-card`. Pure data processing: all
* environment inputs (current time, theme style, hass, resolved forecasts) are
* injected so the transform is deterministic and benchmarkable.
*/
export function generateEnergySolarGraphData(
params: EnergySolarGraphDataParams
): EnergySolarGraphData {
const { hass, energyData, forecasts, computedStyles, now } = params;
const start = energyData.start;
const end = energyData.end || now;
const compareStart = energyData.startCompare;
const compareEnd = energyData.endCompare;
const solarSources: SolarSourceTypeEnergyPreference[] =
energyData.prefs.energy_sources.filter(
(source) => source.type === "solar"
) as SolarSourceTypeEnergyPreference[];
// Both processDataSet calls below receive identical start/end/compareStart,
// so the compare transform and the suggested period are loop-invariant across
// them; compute each once instead of rebuilding the date-fns-heavy values per
// call.
const compareTransform = getCompareTransform(start, compareStart!);
const period = getSuggestedPeriod(start, end);
const datasets: (BarSeriesOption | LineSeriesOption)[] = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number) => {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
};
if (energyData.statsCompare) {
datasets.push(
...processDataSet(
hass,
compareTransform,
period,
energyData.statsCompare,
energyData.statsMetadata,
solarSources,
computedStyles,
trackY,
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: solar` so it doesn't take up space yet
const firstId = solarSources[0]?.stat_energy_from ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "solar",
data: [],
});
}
datasets.push(
...processDataSet(
hass,
compareTransform,
period,
energyData.stats,
energyData.statsMetadata,
solarSources,
computedStyles,
trackY
)
);
fillDataGapsAndRoundCaps(datasets as BarSeriesOption[]);
if (forecasts) {
datasets.push(
...processForecast(
hass,
energyData.statsMetadata,
forecasts,
solarSources,
computedStyles.getPropertyValue("--primary-text-color"),
energyData.start,
energyData.end,
trackY
)
);
}
return {
chartData: datasets,
total: processTotal(energyData.stats, solarSources),
start,
end,
compareStart,
compareEnd,
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax),
};
}
function processTotal(
statistics: Statistics,
solarSources: SolarSourceTypeEnergyPreference[]
) {
return solarSources.reduce(
(sum, source) =>
sum +
(source.stat_energy_from in statistics
? statistics[source.stat_energy_from].reduce(
(acc, curr) => acc + (curr.change || 0),
0
)
: 0),
0
);
}
function processDataSet(
hass: HomeAssistant,
compareTransform: (ts: Date) => Date,
period: "5minute" | "hour" | "day" | "month",
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
solarSources: SolarSourceTypeEnergyPreference[],
computedStyles: CSSStyleDeclaration,
trackY: (v: number) => void,
compare = false
) {
const data: BarSeriesOption[] = [];
// The compare transform only applies to the compare dataset; for the main
// dataset computeStatMidpoint receives `undefined` (loop-invariant).
const midpointTransform = compare ? compareTransform : undefined;
// `period` is fixed for the whole call, so resolve the midpoint mode once
// instead of re-deriving it (and re-comparing the period string) per point.
const center = period === "hour" || period === "5minute";
// hass.themes.darkMode is read twice per source below; cache it once.
const darkMode = hass.themes.darkMode;
solarSources.forEach((source, idx) => {
let prevStart: number | null = null;
const solarProductionData: BarSeriesOption["data"] = [];
// Process solar production data.
if (source.stat_energy_from in statistics) {
const stats = statistics[source.stat_energy_from];
for (const point of stats) {
const change = point.change;
// change is `number | null | undefined`; `== null` matches both null
// and undefined in one comparison.
if (change == null || change === 0) {
continue;
}
const pointStart = point.start;
if (prevStart === pointStart) {
continue;
}
// Inlined computeStatMidpoint with the period branch hoisted out of the
// loop. Arithmetic and operand order are preserved exactly.
let midpoint: number;
if (!center) {
midpoint = midpointTransform
? midpointTransform(new Date(pointStart)).getTime()
: pointStart;
} else if (midpointTransform) {
midpoint =
(midpointTransform(new Date(pointStart)).getTime() +
midpointTransform(new Date(point.end)).getTime()) /
2;
} else {
midpoint = (pointStart + point.end) / 2;
}
const dataPoint: EnergyDataPoint = [midpoint, change, pointStart];
solarProductionData.push(dataPoint);
trackY(change);
prevStart = pointStart;
}
}
data.push({
type: "bar",
cursor: "default",
id: compare
? "compare-" + source.stat_energy_from
: source.stat_energy_from,
name: hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.production",
{
name:
source.name ||
getStatisticLabel(
hass,
source.stat_energy_from,
statisticsMetaData[source.stat_energy_from]
),
}
),
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
computedStyles,
darkMode,
false,
compare,
"--energy-solar-color",
idx
),
},
color: getEnergyColor(
computedStyles,
darkMode,
true,
compare,
"--energy-solar-color",
idx
),
data: solarProductionData,
stack: compare ? "compare" : "solar",
});
});
return data;
}
function processForecast(
hass: HomeAssistant,
statisticsMetaData: Record<string, StatisticsMetaData>,
forecasts: EnergySolarForecasts,
solarSources: SolarSourceTypeEnergyPreference[],
borderColor: string,
start: Date,
end: Date | undefined,
trackY: (v: number) => void
) {
const data: LineSeriesOption[] = [];
// Recompute the period here intentionally — do NOT reuse the hoisted period
// from the entry function. processForecast receives the raw `energyData.end`
// (which may be undefined) whereas the entry function uses `energyData.end ||
// now`, so the two periods can legitimately differ; collapsing them would
// change the forecast hour-flooring near an undefined end.
const period = getSuggestedPeriod(start, end);
// Process solar forecast data.
solarSources.forEach((source) => {
if (source.config_entry_solar_forecast) {
const forecastsData: Record<string, number> | undefined = {};
source.config_entry_solar_forecast.forEach((configEntryId) => {
const forecast = forecasts![configEntryId];
if (!forecast) {
return;
}
Object.entries(forecast.wh_hours).forEach(([date, value]) => {
const dateObj = new Date(date);
if (dateObj < start || (end && dateObj > end)) {
return;
}
if (period === "month") {
dateObj.setDate(1);
}
if (period === "month" || period === "day") {
dateObj.setHours(0, 0, 0, 0);
} else {
dateObj.setMinutes(0, 0, 0);
}
const time = dateObj.getTime();
if (time in forecastsData) {
forecastsData[time] += value;
} else {
forecastsData[time] = value;
}
});
});
if (forecastsData) {
const solarForecastData: LineSeriesOption["data"] = [];
// Only center forecast points for sub-daily periods to align with bars.
// Only start timestamps available, so estimate midpoint from the gap
// between the first two entries. Assumes uniform spacing.
let forecastOffset = 0;
if (period === "hour" || period === "5minute") {
const forecastTimes = Object.keys(forecastsData)
.map(Number)
.sort((a, b) => a - b);
forecastOffset =
forecastTimes.length >= 2
? (forecastTimes[1] - forecastTimes[0]) / 2
: 0;
}
for (const [time, value] of Object.entries(forecastsData)) {
const kWh = value / 1000;
solarForecastData.push([Number(time) + forecastOffset, kWh]);
trackY(kWh);
}
if (solarForecastData.length) {
data.push({
id: "forecast-" + source.stat_energy_from,
type: "line",
stack: "forecast",
name: hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.forecast",
{
name:
source.name ||
getStatisticLabel(
hass,
source.stat_energy_from,
statisticsMetaData[source.stat_energy_from]
),
}
),
step: false,
color: borderColor,
lineStyle: {
type: [7, 5],
width: 1.5,
},
symbol: "none",
data: solarForecastData,
});
}
}
}
});
return data;
}
@@ -6,36 +6,23 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption } from "echarts/charts";
import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
import { computeYAxisFractionDigits } from "../../../../components/chart/y-axis-fraction-digits";
import "../../../../components/ha-card";
import type {
EnergyData,
GasSourceTypeEnergyPreference,
} from "../../../../data/energy";
import type { EnergyData } from "../../../../data/energy";
import {
getEnergyDataCollection,
getSuggestedPeriod,
validateEnergyCollectionKey,
} from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import type { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyGasGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import {
computeStatMidpoint,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import { getCommonOptions } from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import { generateEnergyGasGraphData } from "./energy-gas-graph-data";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -193,173 +180,21 @@ export class HuiEnergyGasGraphCard
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
const gasSources: GasSourceTypeEnergyPreference[] =
energyData.prefs.energy_sources.filter(
(source) => source.type === "gas"
) as GasSourceTypeEnergyPreference[];
this._unit = energyData.gasUnit;
const datasets: BarSeriesOption[] = [];
const computedStyles = getComputedStyle(this);
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number) => {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
};
if (energyData.statsCompare) {
datasets.push(
...this._processDataSet(
energyData.statsCompare,
energyData.statsMetadata,
gasSources,
computedStyles,
trackY,
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: gas` so it doesn't take up space yet
const firstId = gasSources[0]?.stat_energy_from ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "gas",
data: [],
});
}
datasets.push(
...this._processDataSet(
energyData.stats,
energyData.statsMetadata,
gasSources,
computedStyles,
trackY
)
);
fillDataGapsAndRoundCaps(datasets);
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._total = this._processTotal(energyData.stats, gasSources);
}
private _processTotal(
statistics: Statistics,
gasSources: GasSourceTypeEnergyPreference[]
) {
return gasSources.reduce(
(sum, source) =>
sum +
(source.stat_energy_from in statistics
? statistics[source.stat_energy_from].reduce(
(acc, curr) => acc + (curr.change || 0),
0
)
: 0),
0
);
}
private _processDataSet(
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
gasSources: GasSourceTypeEnergyPreference[],
computedStyles: CSSStyleDeclaration,
trackY: (v: number) => void,
compare = false
) {
const data: BarSeriesOption[] = [];
const compareTransform = getCompareTransform(
this._start,
this._compareStart!
);
const period = getSuggestedPeriod(this._start, this._end);
gasSources.forEach((source, idx) => {
let prevStart: number | null = null;
const gasConsumptionData: BarSeriesOption["data"] = [];
// Process gas consumption data.
if (source.stat_energy_from in statistics) {
const stats = statistics[source.stat_energy_from];
for (const point of stats) {
if (
point.change === null ||
point.change === undefined ||
point.change === 0
) {
continue;
}
if (prevStart === point.start) {
continue;
}
const dataPoint: EnergyDataPoint = [
computeStatMidpoint(
point.start,
point.end,
period,
compare ? compareTransform : undefined
),
point.change,
point.start,
];
gasConsumptionData.push(dataPoint);
trackY(point.change);
prevStart = point.start;
}
}
data.push({
type: "bar",
cursor: "default",
id: compare
? "compare-" + source.stat_energy_from
: source.stat_energy_from,
name:
source.name ||
getStatisticLabel(
this.hass,
source.stat_energy_from,
statisticsMetaData[source.stat_energy_from]
),
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
false,
compare,
"--energy-gas-color",
idx
),
},
color: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
true,
compare,
"--energy-gas-color",
idx
),
data: gasConsumptionData,
stack: compare ? "compare-gas" : "gas",
});
const result = generateEnergyGasGraphData({
hass: this.hass,
energyData,
computedStyles: getComputedStyle(this),
now: endOfToday(),
});
return data;
this._start = result.start;
this._end = result.end;
this._compareStart = result.compareStart;
this._compareEnd = result.compareEnd;
this._unit = result.unit;
this._yAxisFractionDigits = result.yAxisFractionDigits;
this._chartData = result.chartData;
this._total = result.total;
}
static styles = css`
@@ -6,10 +6,8 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption, LineSeriesOption } from "echarts/charts";
import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
import { computeYAxisFractionDigits } from "../../../../components/chart/y-axis-fraction-digits";
import "../../../../components/ha-card";
import type {
EnergyData,
@@ -19,24 +17,16 @@ import type {
import {
getEnergyDataCollection,
getEnergySolarForecasts,
getSuggestedPeriod,
validateEnergyCollectionKey,
} from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import type { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergySolarGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import {
computeStatMidpoint,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import { getCommonOptions } from "./common/energy-chart-options";
import { generateEnergySolarGraphData } from "./energy-solar-graph-data";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -191,12 +181,6 @@ export class HuiEnergySolarGraphCard
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
const solarSources: SolarSourceTypeEnergyPreference[] =
energyData.prefs.energy_sources.filter(
(source) => source.type === "solar"
@@ -213,282 +197,21 @@ export class HuiEnergySolarGraphCard
}
}
const datasets: (BarSeriesOption | LineSeriesOption)[] = [];
const computedStyles = getComputedStyle(this);
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number) => {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
};
if (energyData.statsCompare) {
datasets.push(
...this._processDataSet(
energyData.statsCompare,
energyData.statsMetadata,
solarSources,
computedStyles,
trackY,
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: solar` so it doesn't take up space yet
const firstId = solarSources[0]?.stat_energy_from ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "solar",
data: [],
});
}
datasets.push(
...this._processDataSet(
energyData.stats,
energyData.statsMetadata,
solarSources,
computedStyles,
trackY
)
);
fillDataGapsAndRoundCaps(datasets as BarSeriesOption[]);
if (forecasts) {
datasets.push(
...this._processForecast(
energyData.statsMetadata,
forecasts,
solarSources,
computedStyles.getPropertyValue("--primary-text-color"),
energyData.start,
energyData.end,
trackY
)
);
}
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._total = this._processTotal(energyData.stats, solarSources);
}
private _processTotal(
statistics: Statistics,
solarSources: SolarSourceTypeEnergyPreference[]
) {
return solarSources.reduce(
(sum, source) =>
sum +
(source.stat_energy_from in statistics
? statistics[source.stat_energy_from].reduce(
(acc, curr) => acc + (curr.change || 0),
0
)
: 0),
0
);
}
private _processDataSet(
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
solarSources: SolarSourceTypeEnergyPreference[],
computedStyles: CSSStyleDeclaration,
trackY: (v: number) => void,
compare = false
) {
const data: BarSeriesOption[] = [];
const compareTransform = getCompareTransform(
this._start,
this._compareStart!
);
const period = getSuggestedPeriod(this._start, this._end);
solarSources.forEach((source, idx) => {
let prevStart: number | null = null;
const solarProductionData: BarSeriesOption["data"] = [];
// Process solar production data.
if (source.stat_energy_from in statistics) {
const stats = statistics[source.stat_energy_from];
for (const point of stats) {
if (
point.change === null ||
point.change === undefined ||
point.change === 0
) {
continue;
}
if (prevStart === point.start) {
continue;
}
const dataPoint: EnergyDataPoint = [
computeStatMidpoint(
point.start,
point.end,
period,
compare ? compareTransform : undefined
),
point.change,
point.start,
];
solarProductionData.push(dataPoint);
trackY(point.change);
prevStart = point.start;
}
}
data.push({
type: "bar",
cursor: "default",
id: compare
? "compare-" + source.stat_energy_from
: source.stat_energy_from,
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.production",
{
name:
source.name ||
getStatisticLabel(
this.hass,
source.stat_energy_from,
statisticsMetaData[source.stat_energy_from]
),
}
),
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
false,
compare,
"--energy-solar-color",
idx
),
},
color: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
true,
compare,
"--energy-solar-color",
idx
),
data: solarProductionData,
stack: compare ? "compare" : "solar",
});
const result = generateEnergySolarGraphData({
hass: this.hass,
energyData,
forecasts,
computedStyles: getComputedStyle(this),
now: endOfToday(),
});
return data;
}
private _processForecast(
statisticsMetaData: Record<string, StatisticsMetaData>,
forecasts: EnergySolarForecasts,
solarSources: SolarSourceTypeEnergyPreference[],
borderColor: string,
start: Date,
end: Date | undefined,
trackY: (v: number) => void
) {
const data: LineSeriesOption[] = [];
const period = getSuggestedPeriod(start, end);
// Process solar forecast data.
solarSources.forEach((source) => {
if (source.config_entry_solar_forecast) {
const forecastsData: Record<string, number> | undefined = {};
source.config_entry_solar_forecast.forEach((configEntryId) => {
if (!forecasts![configEntryId]) {
return;
}
Object.entries(forecasts![configEntryId].wh_hours).forEach(
([date, value]) => {
const dateObj = new Date(date);
if (dateObj < start || (end && dateObj > end)) {
return;
}
if (period === "month") {
dateObj.setDate(1);
}
if (period === "month" || period === "day") {
dateObj.setHours(0, 0, 0, 0);
} else {
dateObj.setMinutes(0, 0, 0);
}
const time = dateObj.getTime();
if (time in forecastsData) {
forecastsData[time] += value;
} else {
forecastsData[time] = value;
}
}
);
});
if (forecastsData) {
const solarForecastData: LineSeriesOption["data"] = [];
// Only center forecast points for sub-daily periods to align with bars.
// Only start timestamps available, so estimate midpoint from the gap
// between the first two entries. Assumes uniform spacing.
let forecastOffset = 0;
if (period === "hour" || period === "5minute") {
const forecastTimes = Object.keys(forecastsData)
.map(Number)
.sort((a, b) => a - b);
forecastOffset =
forecastTimes.length >= 2
? (forecastTimes[1] - forecastTimes[0]) / 2
: 0;
}
for (const [time, value] of Object.entries(forecastsData)) {
const kWh = value / 1000;
solarForecastData.push([Number(time) + forecastOffset, kWh]);
trackY(kWh);
}
if (solarForecastData.length) {
data.push({
id: "forecast-" + source.stat_energy_from,
type: "line",
stack: "forecast",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.forecast",
{
name:
source.name ||
getStatisticLabel(
this.hass,
source.stat_energy_from,
statisticsMetaData[source.stat_energy_from]
),
}
),
step: false,
color: borderColor,
lineStyle: {
type: [7, 5],
width: 1.5,
},
symbol: "none",
data: solarForecastData,
});
}
}
}
});
return data;
this._start = result.start;
this._end = result.end;
this._compareStart = result.compareStart;
this._compareEnd = result.compareEnd;
this._yAxisFractionDigits = result.yAxisFractionDigits;
this._chartData = result.chartData;
this._total = result.total;
}
static styles = css`
@@ -41,6 +41,7 @@ import {
getCompareTransform,
} from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
const colorPropertyMap = {
to_grid: "--energy-grid-return-color",
@@ -86,6 +87,8 @@ export class HuiEnergyUsageGraphCard
@state() private _yAxisFractionDigits = 1;
@state() private _legendData?: CustomLegendOption["data"];
@state() private _start = startOfToday();
@state() private _end = endOfToday();
@@ -162,7 +165,8 @@ export class HuiEnergyUsageGraphCard
this.hass.config,
this._compareStart,
this._compareEnd,
this._yAxisFractionDigits
this._yAxisFractionDigits,
this._legendData
)}
chart-type="bar"
></ha-chart-base>
@@ -194,7 +198,8 @@ export class HuiEnergyUsageGraphCard
config: HassConfig,
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
yAxisFractionDigits: number,
legendData?: CustomLegendOption["data"]
): HaECOption => {
const commonOptions = getCommonOptions(
start,
@@ -217,6 +222,11 @@ export class HuiEnergyUsageGraphCard
: undefined;
const options: HaECOption = {
...commonOptions,
legend: {
show: this._config?.show_legend !== false,
type: "custom",
data: legendData,
},
tooltip: {
...commonOptions.tooltip,
formatter: (params: TopLevelFormatterParams) => {
@@ -432,9 +442,37 @@ export class HuiEnergyUsageGraphCard
fillDataGapsAndRoundCaps(datasets);
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._legendData = this._getLegendData(datasets);
this._total = this._processTotal(consumption);
}
private _getLegendData(
datasets: BarSeriesOption[]
): CustomLegendOption["data"] {
// Each main series gets a legend item, and its matching compare
// series (if any) is attached as a secondary id so toggling
// the legend item shows/hides both at once.
const compareIds = new Set(
datasets
.map((dataset) => dataset.id as string)
.filter((id) => id?.startsWith("compare-"))
);
return datasets
.filter(
(dataset) =>
dataset.id && !(dataset.id as string).startsWith("compare-")
)
.map((dataset) => {
const id = dataset.id as string;
const compareId = `compare-${id}`;
return {
id,
secondaryIds: compareIds.has(compareId) ? [compareId] : [],
name: dataset.name as string,
};
});
}
private _processTotal(consumption: EnergyConsumptionData) {
return consumption.total.used_total > 0
? consumption.total.used_total
+1
View File
@@ -196,6 +196,7 @@ export interface EnergyDistributionCardConfig extends EnergyCardConfig {
}
export interface EnergyUsageGraphCardConfig extends EnergyCardConfig {
type: "energy-usage-graph";
show_legend?: boolean;
}
export interface EnergySolarGraphCardConfig extends EnergyCardConfig {
@@ -77,7 +77,7 @@ export class HuiEnergyGraphCardEditor
...(type !== "energy-compare"
? [{ name: "title", selector: { text: {} } }]
: []),
...(type === "power-sources-graph"
...(type === "power-sources-graph" || type === "energy-usage-graph"
? [
{
name: "show_legend",
@@ -12,6 +12,7 @@ import "../../../components/ha-switch";
import "../../../components/ha-yaml-editor";
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
@@ -20,8 +21,15 @@ import type { SaveDialogParams } from "./show-save-config-dialog";
const EMPTY_CONFIG: LovelaceConfig = { views: [{ title: "Home" }] };
interface SaveConfigDirtyState {
emptyConfig: boolean;
}
@customElement("hui-dialog-save-config")
export class HuiSaveConfig extends LitElement implements HassDialog {
export class HuiSaveConfig
extends DirtyStateProviderMixin<SaveConfigDirtyState>()(LitElement)
implements HassDialog
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _params?: SaveDialogParams;
@@ -41,6 +49,10 @@ export class HuiSaveConfig extends LitElement implements HassDialog {
this._params = params;
this._emptyConfig = false;
this._open = true;
this._initDirtyTracking(
{ type: "deep" },
{ emptyConfig: this._emptyConfig }
);
}
public closeDialog(): boolean {
@@ -65,7 +77,7 @@ export class HuiSaveConfig extends LitElement implements HassDialog {
<ha-dialog
.open=${this._open}
header-title=${heading}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-icon-button
@@ -165,6 +177,7 @@ export class HuiSaveConfig extends LitElement implements HassDialog {
private _emptyConfigChanged(ev) {
this._emptyConfig = ev.target.checked;
this._updateDirtyState({ emptyConfig: this._emptyConfig });
}
private async _saveConfig(): Promise<void> {
@@ -181,6 +194,7 @@ export class HuiSaveConfig extends LitElement implements HassDialog {
);
lovelace.setEditMode(true);
this._saving = false;
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
alert(`Saving failed: ${err.message}`);
+34 -4
View File
@@ -28,12 +28,23 @@ import {
updateItem,
} from "../../data/todo";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { TodoItemEditDialogParams } from "./show-dialog-todo-item-editor";
interface TodoItemFormState {
summary: string;
description?: string;
due?: Date;
checked: boolean;
hasTime: boolean;
}
@customElement("dialog-todo-item-editor")
class DialogTodoItemEditor extends LitElement {
class DialogTodoItemEditor extends DirtyStateProviderMixin<TodoItemFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: string;
@@ -87,6 +98,17 @@ class DialogTodoItemEditor extends LitElement {
this._checked = false;
this._due = undefined;
}
this._initDirtyTracking({ type: "deep" }, this._currentState());
}
private _currentState(): TodoItemFormState {
return {
summary: this._summary,
description: this._description,
due: this._due,
checked: this._checked,
hasTime: this._hasTime,
};
}
public closeDialog(): void {
@@ -131,7 +153,7 @@ class DialogTodoItemEditor extends LitElement {
`ui.components.todo.item.${isCreate ? "add" : "edit"}`
)}
header-subtitle=${listName}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div class="content">
@@ -224,7 +246,7 @@ class DialogTodoItemEditor extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._createItem}
.disabled=${this._submitting}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize("ui.components.todo.item.add")}
</ha-button>
@@ -233,7 +255,9 @@ class DialogTodoItemEditor extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._saveItem}
.disabled=${!canUpdate || this._submitting}
.disabled=${!canUpdate ||
this._submitting ||
!this.isDirtyState}
>
${this.hass.localize("ui.components.todo.item.save")}
</ha-button>
@@ -299,23 +323,28 @@ class DialogTodoItemEditor extends LitElement {
private _checkedCanged(ev) {
this._checked = ev.target.checked;
this._updateDirtyState(this._currentState());
}
private _handleSummaryChanged(ev: InputEvent) {
this._summary = (ev.target as HaInput).value ?? "";
this._updateDirtyState(this._currentState());
}
private _handleDescriptionChanged(ev) {
this._description = ev.target.value;
this._updateDirtyState(this._currentState());
}
private _dueDateChanged(ev: CustomEvent) {
if (!ev.detail.value) {
this._due = undefined;
this._updateDirtyState(this._currentState());
return;
}
const time = this._due ? this._formatTime(this._due) : undefined;
this._due = this._parseDate(`${ev.detail.value}${time ? `T${time}` : ""}`);
this._updateDirtyState(this._currentState());
}
private _dueTimeChanged(ev: CustomEvent) {
@@ -323,6 +352,7 @@ class DialogTodoItemEditor extends LitElement {
this._due = this._parseDate(
`${this._formatDate(this._due || new Date())}T${ev.detail.value}`
);
this._updateDirtyState(this._currentState());
}
private async _createItem() {
+36 -4
View File
@@ -27,6 +27,8 @@ import {
TimeZone,
} from "../data/translation";
import { subscribeEntityRegistryDisplay } from "../data/ws-entity_registry_display";
import { deepEqual } from "../common/util/deep-equal";
import { preserveUnchangedRecord } from "../common/util/preserve-unchanged-record";
import { subscribeFloorRegistry } from "../data/ws-floor_registry";
import { subscribePanels } from "../data/ws-panels";
import { translationMetadata } from "../resources/translations-metadata";
@@ -265,28 +267,58 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
display_precision: entity.dp,
};
}
this._updateHass({ entities });
const updatedEntities = preserveUnchangedRecord(
this.hass?.entities,
entities,
deepEqual
);
// When the display payload is unchanged (a registry event that doesn't
// touch it), skip the update entirely instead of churning a new hass.
if (updatedEntities !== this.hass?.entities) {
this._updateHass({ entities: updatedEntities });
}
});
subscribeDeviceRegistry(conn, (deviceReg) => {
const devices: HomeAssistant["devices"] = {};
for (const device of deviceReg) {
devices[device.id] = device;
}
this._updateHass({ devices });
const updatedDevices = preserveUnchangedRecord(
this.hass?.devices,
devices,
deepEqual
);
if (updatedDevices !== this.hass?.devices) {
this._updateHass({ devices: updatedDevices });
}
});
subscribeAreaRegistry(conn, (areaReg) => {
const areas: HomeAssistant["areas"] = {};
for (const area of areaReg) {
areas[area.area_id] = area;
}
this._updateHass({ areas });
const updatedAreas = preserveUnchangedRecord(
this.hass?.areas,
areas,
deepEqual
);
if (updatedAreas !== this.hass?.areas) {
this._updateHass({ areas: updatedAreas });
}
});
subscribeFloorRegistry(conn, (floorReg) => {
const floors: HomeAssistant["floors"] = {};
for (const floor of floorReg) {
floors[floor.floor_id] = floor;
}
this._updateHass({ floors });
const updatedFloors = preserveUnchangedRecord(
this.hass?.floors,
floors,
deepEqual
);
if (updatedFloors !== this.hass?.floors) {
this._updateHass({ floors: updatedFloors });
}
});
subscribeConfig(conn, (config) => this._updateHass({ config }));
subscribeServices(conn, (services) => this._updateHass({ services }));
@@ -0,0 +1,83 @@
import { bench, describe } from "vitest";
import { generateEnergyGasGraphData } from "../../src/panels/lovelace/cards/energy/energy-gas-graph-data";
import type { EnergyPreferences } from "../../src/data/energy";
import { createMockComputedStyle } from "../fixtures/computed-style";
import { createMockHass } from "../fixtures/hass";
import { generateEnergyData } from "../fixtures/energy";
// getEnergyColor resolves "--energy-gas-color" (and per-index variants) from
// the computed style; supply a deterministic base color so the palette
// expands to valid hex instead of throwing on an empty string.
const computedStyles = createMockComputedStyle({
"--energy-gas-color": "#1b7ea0",
});
const hass = {
...createMockHass(),
themes: { darkMode: false },
} as any;
const now = new Date("2024-02-15T00:00:00Z");
// Gas-only preferences with several sources to exercise the per-source loop.
const gasPrefs = (sources: number): EnergyPreferences => ({
energy_sources: Array.from({ length: sources }, (_, i) => ({
type: "gas" as const,
stat_energy_from: `sensor.gas_consumption_${i}`,
stat_cost: null,
entity_energy_price: null,
number_energy_price: null,
})),
device_consumption: [],
device_consumption_water: [],
});
const small = generateEnergyData(1, {
days: 1,
period: "hour",
prefs: gasPrefs(2),
});
const medium = generateEnergyData(2, {
days: 31,
period: "hour",
compare: true,
prefs: gasPrefs(3),
});
const large = generateEnergyData(3, {
days: 31,
period: "5minute",
compare: true,
prefs: gasPrefs(4),
});
describe("generateEnergyGasGraphData", () => {
bench("small (1 day hourly, 2 sources)", () => {
generateEnergyGasGraphData({
hass,
energyData: small,
computedStyles,
now,
});
});
bench("medium (month hourly + compare, 3 sources)", () => {
generateEnergyGasGraphData({
hass,
energyData: medium,
computedStyles,
now,
});
});
bench(
"large (month 5-minute + compare, 4 sources)",
() => {
generateEnergyGasGraphData({
hass,
energyData: large,
computedStyles,
now,
});
},
{ time: 1000, warmupIterations: 2 }
);
});
@@ -0,0 +1,137 @@
import { bench, describe } from "vitest";
import { generateEnergySolarGraphData } from "../../src/panels/lovelace/cards/energy/energy-solar-graph-data";
import type {
EnergyPreferences,
EnergySolarForecasts,
SolarSourceTypeEnergyPreference,
} from "../../src/data/energy";
import type { HomeAssistant } from "../../src/types";
import { createMockComputedStyle } from "../fixtures/computed-style";
import { createMockHass } from "../fixtures/hass";
import { generateEnergyData } from "../fixtures/energy";
import { FIXED_EPOCH_MS } from "../fixtures/history-states";
const dayMs = 24 * 60 * 60 * 1000;
const computedStyles = createMockComputedStyle({
"--energy-solar-color": "#ff9800",
"--primary-text-color": "#212121",
});
const hass = {
...createMockHass(),
themes: { darkMode: false },
} as unknown as HomeAssistant;
const solarPrefs = (sources: number, forecast: boolean): EnergyPreferences => {
const energySources: SolarSourceTypeEnergyPreference[] = [];
for (let i = 0; i < sources; i++) {
energySources.push({
type: "solar",
stat_energy_from:
i === 0 ? "sensor.solar_production" : `sensor.solar_production_${i}`,
config_entry_solar_forecast: forecast ? [`entry_${i}`] : null,
} as SolarSourceTypeEnergyPreference);
}
return {
energy_sources: energySources,
device_consumption: [],
device_consumption_water: [],
};
};
const buildForecasts = (
count: number,
stepMs: number,
entries: string[]
): EnergySolarForecasts => {
const result: EnergySolarForecasts = {};
entries.forEach((entry, e) => {
const wh: Record<string, number> = {};
for (let i = 0; i < count; i++) {
const t = new Date(FIXED_EPOCH_MS + i * stepMs);
wh[t.toISOString()] = ((i * 137 + e * 311 + 17) % 5000) + 1;
}
result[entry] = { wh_hours: wh };
});
return result;
};
// small: a couple of days of hourly data, single source
const small = generateEnergyData(1, {
days: 2,
period: "hour",
prefs: solarPrefs(1, false),
});
// medium: a month of hourly data with compare, two sources
const medium = generateEnergyData(2, {
days: 31,
period: "hour",
compare: true,
prefs: solarPrefs(2, false),
});
// large: a month of 5-minute data with compare, two sources
const large = generateEnergyData(3, {
days: 31,
period: "5minute",
compare: true,
prefs: solarPrefs(2, false),
});
// forecast: a month of hourly data with forecast lines, two sources
const forecastData = generateEnergyData(4, {
days: 31,
period: "hour",
prefs: solarPrefs(2, true),
});
const forecasts = buildForecasts(31 * 24, 60 * 60 * 1000, [
"entry_0",
"entry_1",
]);
describe("generateEnergySolarGraphData", () => {
bench("small (2 days hourly, 1 source)", () => {
generateEnergySolarGraphData({
hass,
energyData: { ...small },
forecasts: undefined,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 2 * dayMs),
});
});
bench("medium (month hourly + compare, 2 sources)", () => {
generateEnergySolarGraphData({
hass,
energyData: { ...medium },
forecasts: undefined,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 31 * dayMs),
});
});
bench(
"large (month 5-minute + compare, 2 sources)",
() => {
generateEnergySolarGraphData({
hass,
energyData: { ...large },
forecasts: undefined,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 31 * dayMs),
});
},
{ time: 1000, warmupIterations: 2 }
);
bench("with forecast (month hourly, 2 sources + forecast)", () => {
generateEnergySolarGraphData({
hass,
energyData: { ...forecastData },
forecasts,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 31 * dayMs),
});
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { deepEqual } from "../../../src/common/util/deep-equal";
import { preserveUnchangedRecord } from "../../../src/common/util/preserve-unchanged-record";
interface Item {
id: string;
name: string;
labels?: string[];
}
const record = (...items: Item[]): Record<string, Item> =>
Object.fromEntries(items.map((i) => [i.id, i]));
describe("preserveUnchangedRecord", () => {
it("returns next as-is when there is no previous record", () => {
const next = record({ id: "a", name: "A" });
expect(preserveUnchangedRecord(undefined, next, deepEqual)).toBe(next);
});
it("returns the previous record when nothing changed", () => {
const previous = record({ id: "a", name: "A" }, { id: "b", name: "B" });
const next = record({ id: "a", name: "A" }, { id: "b", name: "B" });
expect(preserveUnchangedRecord(previous, next, deepEqual)).toBe(previous);
});
it("reuses unchanged item objects and only swaps the changed one", () => {
const previous = record({ id: "a", name: "A" }, { id: "b", name: "B" });
const next = record({ id: "a", name: "A" }, { id: "b", name: "B2" });
const result = preserveUnchangedRecord(previous, next, deepEqual);
expect(result).toBe(next);
expect(result.a).toBe(previous.a);
expect(result.b).toBe(next.b);
});
it("deep-compares nested values", () => {
const previous = record({ id: "a", name: "A", labels: ["x", "y"] });
const next = record({ id: "a", name: "A", labels: ["x", "y"] });
// New nested array, equal content -> treated as unchanged.
expect(preserveUnchangedRecord(previous, next, deepEqual)).toBe(previous);
const changedNext = record({ id: "a", name: "A", labels: ["x", "z"] });
const result = preserveUnchangedRecord(previous, changedNext, deepEqual);
expect(result).toBe(changedNext);
expect(result.a).toBe(changedNext.a);
});
it("returns next when an item is added", () => {
const previous = record({ id: "a", name: "A" });
const next = record({ id: "a", name: "A" }, { id: "b", name: "B" });
const result = preserveUnchangedRecord(previous, next, deepEqual);
expect(result).toBe(next);
expect(result.a).toBe(previous.a);
});
it("returns next when an item is removed", () => {
const previous = record({ id: "a", name: "A" }, { id: "b", name: "B" });
const next = record({ id: "a", name: "A" });
expect(preserveUnchangedRecord(previous, next, deepEqual)).toBe(next);
});
});
@@ -1,5 +1,786 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`HistoryStream characterization > adds a new entity, keeps an absent one, and sorts out-of-order states > multi-incremental 1`] = `
{
"sensor.power_a": [
{
"a": {
"device_class": "power",
"state_class": "measurement",
"unit_of_measurement": "W",
},
"lu": 1704146700,
"s": "138.89",
},
{
"a": {},
"lu": 1704146760,
"s": "146.73",
},
{
"a": {},
"lu": 1704146820,
"s": "145.59",
},
{
"a": {},
"lu": 1704146880,
"s": "142.40",
},
{
"a": {},
"lu": 1704146940,
"s": "134.33",
},
{
"a": {},
"lu": 1704147000,
"s": "135.42",
},
{
"a": {},
"lc": 1704147000,
"lu": 1704147060,
"s": "135.42",
},
{
"a": {},
"lu": 1704147120,
"s": "136.41",
},
{
"a": {},
"lc": 1704147120,
"lu": 1704147180,
"s": "136.41",
},
{
"a": {},
"lu": 1704147240,
"s": "144.38",
},
{
"a": {},
"lu": 1704147300,
"s": "138.05",
},
{
"a": {},
"lu": 1704147360,
"s": "134.28",
},
{
"a": {},
"lu": 1704147420,
"s": "132.05",
},
{
"a": {},
"lu": 1704147480,
"s": "137.12",
},
{
"a": {},
"lu": 1704147540,
"s": "145.55",
},
{
"a": {},
"lu": 1704147600,
"s": "138.11",
},
{
"a": {},
"lc": 1704147600,
"lu": 1704147660,
"s": "138.11",
},
{
"a": {},
"lu": 1704147720,
"s": "135.95",
},
{
"a": {},
"lu": 1704147780,
"s": "130.24",
},
{
"a": {},
"lu": 1704147840,
"s": "120.94",
},
{
"a": {},
"lc": 1704147840,
"lu": 1704147900,
"s": "120.94",
},
{
"a": {},
"lu": 1704147960,
"s": "125.98",
},
{
"a": {},
"lu": 1704148020,
"s": "120.26",
},
{
"a": {},
"lu": 1704148080,
"s": "115.37",
},
{
"a": {},
"lc": 1704148080,
"lu": 1704148140,
"s": "115.37",
},
{
"a": {},
"lu": 1704148200,
"s": "121.80",
},
{
"a": {
"device_class": "power",
"state_class": "measurement",
"unit_of_measurement": "W",
},
"lu": 1704148200,
"s": "111.91",
},
{
"a": {},
"lu": 1704148230,
"s": "106.20",
},
{
"a": {},
"lu": 1704148260,
"s": "118.13",
},
{
"a": {},
"lu": 1704148260,
"s": "112.72",
},
{
"a": {},
"lu": 1704148290,
"s": "106.90",
},
{
"a": {},
"lu": 1704148320,
"s": "123.85",
},
{
"a": {},
"lu": 1704148320,
"s": "113.68",
},
{
"a": {},
"lu": 1704148350,
"s": "104.79",
},
{
"a": {},
"lu": 1704148380,
"s": "117.09",
},
{
"a": {},
"lu": 1704148380,
"s": "114.50",
},
{
"a": {},
"lu": 1704148410,
"s": "109.11",
},
{
"a": {},
"lu": 1704148440,
"s": "108.15",
},
{
"a": {},
"lu": 1704148440,
"s": "102.25",
},
{
"a": {},
"lu": 1704148470,
"s": "102.42",
},
{
"a": {},
"lu": 1704148500,
"s": "105.34",
},
{
"a": {},
"lu": 1704148560,
"s": "102.07",
},
{
"a": {},
"lu": 1704148620,
"s": "108.77",
},
{
"a": {},
"lu": 1704148680,
"s": "108.01",
},
{
"a": {},
"lu": 1704148740,
"s": "98.07",
},
{
"a": {},
"lc": 1704148740,
"lu": 1704148800,
"s": "98.07",
},
{
"a": {},
"lu": 1704148860,
"s": "93.40",
},
{
"a": {},
"lu": 1704148920,
"s": "87.89",
},
{
"a": {},
"lu": 1704148980,
"s": "87.22",
},
{
"a": {},
"lu": 1704149040,
"s": "77.55",
},
{
"a": {},
"lu": 1704149100,
"s": "82.38",
},
{
"a": {},
"lu": 1704149160,
"s": "84.01",
},
{
"a": {},
"lu": 1704149220,
"s": "83.39",
},
{
"a": {},
"lu": 1704149280,
"s": "74.55",
},
{
"a": {},
"lu": 1704149340,
"s": "77.64",
},
{
"a": {},
"lu": 1704149400,
"s": "75.78",
},
{
"a": {},
"lu": 1704149460,
"s": "77.98",
},
{
"a": {},
"lc": 1704149460,
"lu": 1704149520,
"s": "77.98",
},
{
"a": {},
"lu": 1704149580,
"s": "72.36",
},
{
"a": {},
"lc": 1704149580,
"lu": 1704149640,
"s": "72.36",
},
{
"a": {},
"lu": 1704149700,
"s": "81.92",
},
{
"a": {},
"lu": 1704149760,
"s": "80.90",
},
{
"a": {},
"lu": 1704149820,
"s": "78.80",
},
{
"a": {},
"lu": 1704149880,
"s": "82.16",
},
{
"a": {},
"lu": 1704149940,
"s": "73.00",
},
{
"a": {},
"lu": 1704150000,
"s": "71.57",
},
{
"a": {},
"lu": 1704150060,
"s": "76.99",
},
{
"a": {},
"lu": 1704150120,
"s": "74.07",
},
{
"a": {},
"lu": 1704150180,
"s": "81.70",
},
{
"a": {},
"lu": 1704150240,
"s": "86.24",
},
],
"sensor.power_b": [
{
"a": {
"device_class": "power",
"state_class": "measurement",
"unit_of_measurement": "W",
},
"lu": 1704146700,
"s": "156.69",
},
{
"a": {},
"lu": 1704146760,
"s": "164.63",
},
{
"a": {},
"lu": 1704146820,
"s": "169.53",
},
{
"a": {},
"lc": 1704146820,
"lu": 1704146880,
"s": "169.53",
},
{
"a": {},
"lu": 1704146940,
"s": "169.48",
},
{
"a": {},
"lu": 1704147000,
"s": "163.97",
},
{
"a": {},
"lu": 1704147060,
"s": "167.29",
},
{
"a": {},
"lc": 1704147060,
"lu": 1704147120,
"s": "167.29",
},
{
"a": {},
"lu": 1704147180,
"s": "157.38",
},
{
"a": {},
"lu": 1704147240,
"s": "156.66",
},
{
"a": {},
"lu": 1704147300,
"s": "162.98",
},
{
"a": {},
"lu": 1704147360,
"s": "153.89",
},
{
"a": {},
"lu": 1704147420,
"s": "146.45",
},
{
"a": {},
"lu": 1704147480,
"s": "138.87",
},
{
"a": {},
"lu": 1704147540,
"s": "129.95",
},
{
"a": {},
"lu": 1704147600,
"s": "120.70",
},
{
"a": {},
"lu": 1704147660,
"s": "115.92",
},
{
"a": {},
"lu": 1704147720,
"s": "114.26",
},
{
"a": {},
"lu": 1704147780,
"s": "122.21",
},
{
"a": {},
"lu": 1704147840,
"s": "117.68",
},
{
"a": {},
"lu": 1704147900,
"s": "118.17",
},
{
"a": {},
"lu": 1704147960,
"s": "110.60",
},
{
"a": {},
"lu": 1704148020,
"s": "102.80",
},
{
"a": {},
"lu": 1704148080,
"s": "108.55",
},
{
"a": {},
"lu": 1704148140,
"s": "106.53",
},
{
"a": {},
"lc": 1704148140,
"lu": 1704148200,
"s": "106.53",
},
{
"a": {},
"lu": 1704148260,
"s": "103.79",
},
{
"a": {},
"lu": 1704148320,
"s": "96.61",
},
{
"a": {},
"lu": 1704148380,
"s": "96.37",
},
{
"a": {},
"lu": 1704148440,
"s": "93.71",
},
{
"a": {},
"lu": 1704148500,
"s": "89.91",
},
{
"a": {},
"lu": 1704148560,
"s": "91.07",
},
{
"a": {},
"lu": 1704148620,
"s": "89.65",
},
{
"a": {},
"lu": 1704148680,
"s": "87.00",
},
{
"a": {},
"lc": 1704148680,
"lu": 1704148740,
"s": "87.00",
},
{
"a": {},
"lc": 1704148680,
"lu": 1704148800,
"s": "87.00",
},
{
"a": {},
"lu": 1704148860,
"s": "86.74",
},
{
"a": {},
"lu": 1704148920,
"s": "86.65",
},
{
"a": {},
"lu": 1704148980,
"s": "76.83",
},
{
"a": {},
"lu": 1704149040,
"s": "78.89",
},
{
"a": {},
"lu": 1704149100,
"s": "81.20",
},
{
"a": {},
"lu": 1704149160,
"s": "91.15",
},
{
"a": {},
"lu": 1704149220,
"s": "82.79",
},
{
"a": {},
"lu": 1704149280,
"s": "83.28",
},
{
"a": {},
"lu": 1704149340,
"s": "86.50",
},
{
"a": {},
"lu": 1704149400,
"s": "86.21",
},
{
"a": {},
"lu": 1704149460,
"s": "85.99",
},
{
"a": {},
"lu": 1704149520,
"s": "82.53",
},
{
"a": {},
"lu": 1704149580,
"s": "89.38",
},
{
"a": {},
"lu": 1704149640,
"s": "97.42",
},
{
"a": {},
"lu": 1704149700,
"s": "93.07",
},
{
"a": {},
"lc": 1704149700,
"lu": 1704149760,
"s": "93.07",
},
{
"a": {},
"lc": 1704149700,
"lu": 1704149820,
"s": "93.07",
},
{
"a": {},
"lu": 1704149880,
"s": "89.71",
},
{
"a": {},
"lu": 1704149940,
"s": "81.27",
},
{
"a": {},
"lu": 1704150000,
"s": "73.73",
},
{
"a": {},
"lu": 1704150060,
"s": "79.25",
},
{
"a": {},
"lu": 1704150120,
"s": "78.33",
},
{
"a": {},
"lu": 1704150180,
"s": "68.70",
},
{
"a": {},
"lu": 1704150240,
"s": "69.64",
},
],
"sensor.power_c": [
{
"a": {
"device_class": "power",
"state_class": "measurement",
"unit_of_measurement": "W",
},
"lu": 1704153000,
"s": "129.08",
},
{
"a": {},
"lu": 1704153030,
"s": "121.42",
},
{
"a": {},
"lu": 1704153060,
"s": "128.97",
},
{
"a": {},
"lu": 1704153090,
"s": "125.56",
},
{
"a": {},
"lu": 1704153120,
"s": "128.99",
},
{
"a": {},
"lu": 1704153150,
"s": "137.29",
},
{
"a": {},
"lu": 1704153180,
"s": "142.91",
},
{
"a": {},
"lu": 1704153210,
"s": "145.58",
},
{
"a": {},
"lc": 1704153210,
"lu": 1704153240,
"s": "145.58",
},
{
"a": {},
"lu": 1704153270,
"s": "154.08",
},
{
"a": {},
"lu": 1704153300,
"s": "148.39",
},
{
"a": {},
"lu": 1704153330,
"s": "146.42",
},
{
"a": {},
"lc": 1704153330,
"lu": 1704153360,
"s": "146.42",
},
{
"a": {},
"lu": 1704153390,
"s": "144.87",
},
{
"a": {},
"lu": 1704153420,
"s": "152.62",
},
],
}
`;
exports[`HistoryStream characterization > adds a new entity, keeps an absent one, and sorts out-of-order states > multi-initial 1`] = `
{
"keys": [
"sensor.power_a",
"sensor.power_b",
],
"numberCount": 135,
"numberSum": "230060040600",
"type": "object",
}
`;
exports[`HistoryStream characterization > handles multiple entities 1`] = `
{
"binary_sensor.motion_hall": [
@@ -3054,6 +3835,98 @@ exports[`computeHistory characterization > matches snapshot with splitDeviceClas
}
`;
exports[`computeHistory characterization > resolves non-numeric domain units (zone, humidifier, water_heater) 1`] = `
{
"line": [
{
"data": [
{
"domain": "zone",
"entity_id": "zone.home",
"name": "Home zone",
"states": [
{
"attributes": {},
"last_changed": 1700000000000,
"state": "2",
},
{
"attributes": {},
"last_changed": 1700000060000,
"state": "3",
},
],
},
],
"device_class": "info_control.zone.graph_unit",
"identifier": "zone.home",
"unit": "ui.dialogs.more",
},
{
"data": [
{
"domain": "humidifier",
"entity_id": "humidifier.bedroom",
"name": "Bedroom humidifier",
"states": [
{
"attributes": {
"humidity": 45,
"mode": "auto",
},
"last_changed": 1700000000000,
"state": "on",
},
{
"attributes": {
"humidity": 50,
"mode": "auto",
},
"last_changed": 1700000060000,
"state": "on",
},
],
},
],
"device_class": undefined,
"identifier": "humidifier.bedroom",
"unit": "%",
},
{
"data": [
{
"domain": "water_heater",
"entity_id": "water_heater.tank",
"name": "Water heater",
"states": [
{
"attributes": {
"current_temperature": 48,
"temperature": 50,
},
"last_changed": 1700000000000,
"state": "eco",
},
{
"attributes": {
"current_temperature": 49,
"temperature": 55,
},
"last_changed": 1700000060000,
"state": "eco",
},
],
},
],
"device_class": undefined,
"identifier": "water_heater.tank",
"unit": "°C",
},
],
"timeline": [],
}
`;
exports[`convertStatisticsToHistory characterization > converts mean-based statistics 1`] = `
{
"line": [
+100
View File
@@ -115,6 +115,54 @@ describe("computeHistory characterization", () => {
)
).toMatchSnapshot();
});
it("resolves non-numeric domain units (zone, humidifier, water_heater)", () => {
// Pins the per-domain unit lookup for non-numeric line entities, which the
// mixed/climate fixtures above do not exercise.
const history = {
"zone.home": [
{ s: "2", a: { friendly_name: "Home zone" }, lu: 1_700_000_000 },
{ s: "3", a: {}, lu: 1_700_000_060 },
],
"humidifier.bedroom": [
{
s: "on",
a: {
friendly_name: "Bedroom humidifier",
humidity: 45,
mode: "auto",
},
lu: 1_700_000_000,
},
{ s: "on", a: { humidity: 50, mode: "auto" }, lu: 1_700_000_060 },
],
"water_heater.tank": [
{
s: "eco",
a: {
friendly_name: "Water heater",
temperature: 50,
current_temperature: 48,
},
lu: 1_700_000_000,
},
{
s: "eco",
a: { temperature: 55, current_temperature: 49 },
lu: 1_700_000_060,
},
],
};
expect(
computeHistory(
hass,
history,
[],
mockLocalize,
SENSOR_NUMERIC_DEVICE_CLASSES
)
).toMatchSnapshot();
});
});
describe("HistoryStream characterization", () => {
@@ -183,6 +231,58 @@ describe("HistoryStream characterization", () => {
});
expect(result).toMatchSnapshot();
});
it("adds a new entity, keeps an absent one, and sorts out-of-order states", () => {
const nowMs = FIXED_EPOCH_MS + 24 * 60 * 60 * 1000;
vi.useFakeTimers();
vi.setSystemTime(nowMs);
const hoursToShow = 2;
const stream = new HistoryStream(createMockHass(), hoursToShow);
const windowStartMs = nowMs - hoursToShow * 60 * 60 * 1000;
// Initial chunk with two entities.
const initial = stream.processMessage({
states: {
"sensor.power_a": generateNumericSensorStates(21, {
count: 60,
startMs: windowStartMs + 5 * 60 * 1000,
intervalMs: 60 * 1000,
jitter: 0,
}),
"sensor.power_b": generateNumericSensorStates(22, {
count: 60,
startMs: windowStartMs + 5 * 60 * 1000,
intervalMs: 60 * 1000,
jitter: 0,
}),
},
});
expect(digestResult(initial)).toMatchSnapshot("multi-initial");
// Incremental update that:
// - updates only sensor.power_a (sensor.power_b is absent and must be kept)
// - introduces a brand-new entity sensor.power_c (stream-only branch)
// - delivers sensor.power_a states out of order (older lu than the last
// combined state) to exercise the sort path
const incremental = stream.processMessage({
states: {
"sensor.power_a": generateNumericSensorStates(23, {
count: 10,
startMs: nowMs - 90 * 60 * 1000,
intervalMs: 30 * 1000,
jitter: 0,
}),
"sensor.power_c": generateNumericSensorStates(24, {
count: 15,
startMs: nowMs - 10 * 60 * 1000,
intervalMs: 30 * 1000,
jitter: 0,
}),
},
});
expect(incremental).toMatchSnapshot("multi-incremental");
});
});
describe("convertStatisticsToHistory characterization", () => {
@@ -272,6 +272,62 @@ describe("fillLineGaps", () => {
assert.equal(getX(secondItem), 2000);
assert.equal(secondItem.itemStyle.color, "red");
});
it("keeps the last item when a dataset has duplicate timestamps", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
[1000, 10],
[1000, 99],
[2000, 20],
],
},
];
const result = fillLineGaps(datasets);
// Two distinct buckets; the later [1000, 99] wins for bucket 1000.
assert.equal(result[0].data!.length, 2);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 99);
assert.equal(getX(result[0].data![1]), 2000);
assert.equal(getY(result[0].data![1]), 20);
});
it("produces a NaN bucket filled with zero across datasets", () => {
// A datapoint with no numeric x coerces to NaN. It adds a NaN bucket but is
// never stored in any dataset's map, so every dataset gets [NaN, 0] there.
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
[1000, 10],
[Number.NaN, 50],
],
},
{
type: "line",
data: [[1000, 100]],
},
];
const result = fillLineGaps(datasets);
// Buckets present: 1000 and NaN (NaN sorts to the end).
assert.equal(result[0].data!.length, 2);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 10);
assert.isTrue(Number.isNaN(getX(result[0].data![1])));
assert.equal(getY(result[0].data![1]), 0);
// Second dataset is aligned to the same buckets, NaN filled with zero.
assert.equal(result[1].data!.length, 2);
assert.equal(getX(result[1].data![0]), 1000);
assert.equal(getY(result[1].data![0]), 100);
assert.isTrue(Number.isNaN(getX(result[1].data![1])));
assert.equal(getY(result[1].data![1]), 0);
});
});
// Helper to get bar data item
@@ -0,0 +1,212 @@
/**
* Characterization tests pinning the exact output of the gas energy graph
* card's data transform. Do NOT update these snapshots to make an
* optimization pass see test/benchmarks/README.md.
*/
import { describe, expect, it } from "vitest";
import { generateEnergyGasGraphData } from "../../../../../src/panels/lovelace/cards/energy/energy-gas-graph-data";
import type { EnergyPreferences } from "../../../../../src/data/energy";
import type { HomeAssistant } from "../../../../../src/types";
import { createMockComputedStyle } from "../../../../fixtures/computed-style";
import { digestResult } from "../../../../fixtures/digest";
import {
createMockEntityState,
createMockHass,
} from "../../../../fixtures/hass";
import {
generateEnergyData,
generateEnergyPreferences,
} from "../../../../fixtures/energy";
// getEnergyColor resolves "--energy-gas-color" (and per-index variants) from
// the computed style, so supply a deterministic base color for the palette.
const computedStyles = createMockComputedStyle({
"--energy-gas-color": "#1b7ea0",
});
// The transform reads hass.themes.darkMode and hass.states (via
// getStatisticLabel). createMockHass covers states; layer themes on top.
const makeHass = (overrides: Partial<HomeAssistant> = {}): HomeAssistant =>
({
...createMockHass(),
themes: { darkMode: false },
...overrides,
}) as unknown as HomeAssistant;
// Energy preferences with only gas sources (the card filters to type "gas").
const gasOnlyPrefs = (sources: number): EnergyPreferences => ({
energy_sources: Array.from({ length: sources }, (_, i) => ({
type: "gas" as const,
stat_energy_from: `sensor.gas_consumption_${i}`,
stat_cost: null,
entity_energy_price: null,
number_energy_price: null,
})),
device_consumption: [],
device_consumption_water: [],
});
// Fixed "now" so the end fallback is deterministic.
const now = new Date("2024-01-31T23:59:59Z");
describe("generateEnergyGasGraphData", () => {
it("matches snapshot for a single gas source (no compare)", () => {
const energyData = generateEnergyData(1, {
days: 2,
period: "hour",
prefs: gasOnlyPrefs(1),
});
expect(
generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("matches snapshot for multiple gas sources (color brightening by idx)", () => {
const energyData = generateEnergyData(2, {
days: 2,
period: "hour",
prefs: gasOnlyPrefs(3),
});
expect(
generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("matches snapshot with compare data", () => {
const energyData = generateEnergyData(3, {
days: 2,
period: "hour",
compare: true,
prefs: gasOnlyPrefs(2),
});
expect(
generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("matches snapshot in dark mode", () => {
const energyData = generateEnergyData(4, {
days: 2,
period: "hour",
prefs: gasOnlyPrefs(2),
});
expect(
generateEnergyGasGraphData({
hass: makeHass({
themes: { darkMode: true } as HomeAssistant["themes"],
}),
energyData,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("uses the source name when provided, and the entity name from hass.states", () => {
const prefs: EnergyPreferences = {
energy_sources: [
{
type: "gas",
stat_energy_from: "sensor.gas_named",
name: "My gas meter",
stat_cost: null,
entity_energy_price: null,
number_energy_price: null,
},
{
type: "gas",
stat_energy_from: "sensor.gas_from_state",
stat_cost: null,
entity_energy_price: null,
number_energy_price: null,
},
],
device_consumption: [],
device_consumption_water: [],
};
const energyData = generateEnergyData(5, {
days: 2,
period: "hour",
prefs,
});
const hass = makeHass({
states: {
"sensor.gas_from_state": createMockEntityState(
"sensor.gas_from_state",
"42",
{ friendly_name: "Kitchen gas" }
),
} as HomeAssistant["states"],
});
expect(
generateEnergyGasGraphData({ hass, energyData, computedStyles, now })
).toMatchSnapshot();
});
it("handles no gas sources (empty placeholder dataset)", () => {
const energyData = generateEnergyData(6, {
days: 2,
period: "hour",
prefs: generateEnergyPreferences({ grid: true, solar: true }),
});
expect(
generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("falls back to now when energyData.end is missing", () => {
const energyData = generateEnergyData(7, {
days: 1,
period: "hour",
prefs: gasOnlyPrefs(1),
});
// Force the missing-end branch.
(energyData as { end?: Date }).end = undefined;
const result = generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
});
expect(result.end).toBe(now);
});
it("large 5-minute payload digest is stable (compare)", () => {
const energyData = generateEnergyData(42, {
days: 31,
period: "5minute",
compare: true,
prefs: gasOnlyPrefs(3),
});
expect(
digestResult(
generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
})
)
).toMatchSnapshot();
});
});
@@ -0,0 +1,212 @@
/**
* Characterization tests pinning the exact output of the solar energy graph
* card data transform. Do NOT update these snapshots to make an optimization
* pass see test/benchmarks/README.md.
*/
import { describe, expect, it } from "vitest";
import { generateEnergySolarGraphData } from "../../../../../src/panels/lovelace/cards/energy/energy-solar-graph-data";
import type {
EnergyPreferences,
EnergySolarForecasts,
SolarSourceTypeEnergyPreference,
} from "../../../../../src/data/energy";
import type { HomeAssistant } from "../../../../../src/types";
import { createMockComputedStyle } from "../../../../fixtures/computed-style";
import { digestResult } from "../../../../fixtures/digest";
import { createMockHass } from "../../../../fixtures/hass";
import { generateEnergyData } from "../../../../fixtures/energy";
import { FIXED_EPOCH_MS } from "../../../../fixtures/history-states";
const dayMs = 24 * 60 * 60 * 1000;
const computedStyles = createMockComputedStyle({
"--energy-solar-color": "#ff9800",
"--primary-text-color": "#212121",
});
const hass = {
...createMockHass(),
themes: { darkMode: false },
} as unknown as HomeAssistant;
const now = new Date(FIXED_EPOCH_MS + dayMs);
/** Prefs with one or two solar sources, optionally with forecast entries. */
const solarPrefs = (opts: {
sources?: number;
forecast?: boolean;
}): EnergyPreferences => {
const { sources = 1, forecast = false } = opts;
const energySources: SolarSourceTypeEnergyPreference[] = [];
for (let i = 0; i < sources; i++) {
energySources.push({
type: "solar",
stat_energy_from:
i === 0 ? "sensor.solar_production" : `sensor.solar_production_${i}`,
config_entry_solar_forecast: forecast ? [`entry_${i}`] : null,
...(i > 0 ? { name: `Roof ${i}` } : {}),
} as SolarSourceTypeEnergyPreference);
}
return {
energy_sources: energySources,
device_consumption: [],
device_consumption_water: [],
};
};
/** Deterministic forecast payload aligned to the data window. */
const buildForecasts = (
count: number,
stepMs: number,
entries: string[]
): EnergySolarForecasts => {
const result: EnergySolarForecasts = {};
entries.forEach((entry, e) => {
const wh: Record<string, number> = {};
for (let i = 0; i < count; i++) {
const t = new Date(FIXED_EPOCH_MS + i * stepMs);
wh[t.toISOString()] = ((i * 137 + e * 311 + 17) % 5000) + 1;
}
result[entry] = { wh_hours: wh };
});
return result;
};
describe("generateEnergySolarGraphData", () => {
it("returns a placeholder dataset and zero total for no solar sources", () => {
const energyData = generateEnergyData(1, {
days: 1,
period: "hour",
prefs: {
energy_sources: [],
device_consumption: [],
device_consumption_water: [],
},
});
const result = generateEnergySolarGraphData({
hass,
energyData,
forecasts: undefined,
computedStyles,
now,
});
expect(result.total).toBe(0);
expect(result).toMatchSnapshot();
});
it("matches snapshot for a single solar source, hourly, no compare", () => {
const energyData = generateEnergyData(2, {
days: 2,
period: "hour",
prefs: solarPrefs({ sources: 1 }),
});
expect(
generateEnergySolarGraphData({
hass,
energyData,
forecasts: undefined,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("matches snapshot for two solar sources with compare", () => {
const energyData = generateEnergyData(3, {
days: 2,
period: "hour",
compare: true,
prefs: solarPrefs({ sources: 2 }),
});
const result = generateEnergySolarGraphData({
hass,
energyData,
forecasts: undefined,
computedStyles,
now,
});
// compare statistics produce `compare-` prefixed datasets
expect(
result.chartData.some((d) => String(d.id).startsWith("compare-"))
).toBe(true);
expect(result).toMatchSnapshot();
});
it("matches snapshot for the daily period", () => {
const energyData = generateEnergyData(4, {
days: 40,
period: "day",
prefs: solarPrefs({ sources: 1 }),
});
expect(
generateEnergySolarGraphData({
hass,
energyData,
forecasts: undefined,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("matches snapshot with hourly forecast data (sub-daily centering)", () => {
const energyData = generateEnergyData(5, {
days: 2,
period: "hour",
prefs: solarPrefs({ sources: 1, forecast: true }),
});
const forecasts = buildForecasts(2 * 24, 60 * 60 * 1000, ["entry_0"]);
const result = generateEnergySolarGraphData({
hass,
energyData,
forecasts,
computedStyles,
now,
});
expect(
result.chartData.some((d) => String(d.id).startsWith("forecast-"))
).toBe(true);
expect(result).toMatchSnapshot();
});
it("matches snapshot with daily-period forecast data (no centering)", () => {
const energyData = generateEnergyData(6, {
days: 40,
period: "day",
prefs: solarPrefs({ sources: 2, forecast: true }),
});
const forecasts = buildForecasts(40 * 24, 60 * 60 * 1000, [
"entry_0",
"entry_1",
]);
expect(
generateEnergySolarGraphData({
hass,
energyData,
forecasts,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 40 * dayMs),
})
).toMatchSnapshot();
});
it("large 5-minute payload digest is stable", () => {
const energyData = generateEnergyData(42, {
days: 31,
period: "5minute",
compare: true,
prefs: solarPrefs({ sources: 2 }),
});
expect(
digestResult(
generateEnergySolarGraphData({
hass,
energyData,
forecasts: undefined,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 31 * dayMs),
})
)
).toMatchSnapshot();
});
});
+10 -10
View File
@@ -7396,9 +7396,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.4.1":
version: 10.4.1
resolution: "eslint@npm:10.4.1"
"eslint@npm:10.5.0":
version: 10.5.0
resolution: "eslint@npm:10.5.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
@@ -7437,7 +7437,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/5722bd0ec1a87f49ee4511c7549dcfa69df0076927b0290b0a3b145425fd357906ffe6dc1307214a0bd344131bf53795fa2cdebfd36ee9146c01d98147044679
checksum: 10/28882f9b00803fca938015894d6e1ac2c80e00c1349385764f54ac4c0707c33fd0e32b678845c54ea0bc8ae04d59d7aa93d68dbd835e5e4833c65bf9b15ea91f
languageName: node
linkType: hard
@@ -8526,7 +8526,7 @@ __metadata:
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.1.0"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.4.1"
eslint: "npm:10.5.0"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.16.2"
@@ -9695,12 +9695,12 @@ __metadata:
linkType: hard
"launch-editor@npm:^2.13.2":
version: 2.13.2
resolution: "launch-editor@npm:2.13.2"
version: 2.14.1
resolution: "launch-editor@npm:2.14.1"
dependencies:
picocolors: "npm:^1.1.1"
shell-quote: "npm:^1.8.3"
checksum: 10/2b718ae4d3494526c9493a8c8f32e3824a79885e3b3be2e7e0db5ff74811b12af41760c4b904692cb43ddbd815ce65be245910e7ae84c3cc8ecbad4923657115
shell-quote: "npm:^1.8.4"
checksum: 10/335d12ca437280e77070657531c251b6c91c62bc653f70ab66ddd2a6e50131b1b043480411c5b93d54955a0a6eb8ec01e9a5b5cfe2d887341d878d19394a126b
languageName: node
linkType: hard
@@ -12266,7 +12266,7 @@ __metadata:
languageName: node
linkType: hard
"shell-quote@npm:^1.8.3":
"shell-quote@npm:^1.8.4":
version: 1.8.4
resolution: "shell-quote@npm:1.8.4"
checksum: 10/a3e3796385f2cd5cf0b78207a4439f0c7395c0833fc75b2473084b5d298c109c5c0fa687fcd1c04e4b4484866e5bb8eaae7efae443b80fff71ea7e29baf11f0c