mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-15 21:02:10 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22786df070 | |||
| e5c849359b | |||
| 4bfa4f2816 | |||
| afd86975d6 | |||
| 7b1eff9eef | |||
| 4f0c228756 | |||
| c86101ac6e | |||
| 29fa351b16 | |||
| 7c67633146 | |||
| 180e23ad9b | |||
| 9e7ddb3e5e | |||
| 4a0e46dc2c | |||
| 6af0040e73 | |||
| ba58ef6dc2 | |||
| fafbd7a674 | |||
| 07290a5d7e | |||
| 06141043a7 | |||
| 03e4f968b4 | |||
| 17d4f67f69 |
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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. There’s 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
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+27
-2
@@ -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 =
|
||||
|
||||
+12
-3
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+34
-16
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+6451
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user