Compare commits

...

12 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 21:18:48 +02:00
renovate[bot] e5c849359b Update eslint monorepo to v10.5.0 (#52659)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 20:09:16 +02:00
Abílio Costa 4bfa4f2816 Add legend filter to energy usage graph card (#52485) 2026-06-15 19:42:49 +02:00
Bram Kragten afd86975d6 Fix date dedupe in statistics chart (#52656) 2026-06-15 18:10:31 +02:00
Petar Petrov 7b1eff9eef Optimize energy gas graph card data generation (#52654) 2026-06-15 16:50:17 +02:00
Petar Petrov 4f0c228756 Optimize energy solar graph card data generation (#52653) 2026-06-15 16:49:55 +02:00
Petar Petrov c86101ac6e Optimize energy data processing (#52648) 2026-06-15 16:42:25 +02:00
Petar Petrov 29fa351b16 Optimize history data processing (#52646) 2026-06-15 16:37:25 +02:00
Petar Petrov 7c67633146 Optimize energy chart line gap filling (#52645) 2026-06-15 16:36:13 +02:00
Petar Petrov 180e23ad9b Optimize statistics chart data generation (#52644) 2026-06-15 16:35:34 +02:00
Franck Nijhof 9e7ddb3e5e Preserve unchanged device, area, and floor registry entries (#52655) 2026-06-15 16:04:40 +02:00
Aidan Timson 4a0e46dc2c Subsections for gallery sidebar (#52640)
Implement sections for gallery sidebar
2026-06-15 16:38:00 +03:00
27 changed files with 16302 additions and 655 deletions
+18 -1
View File
@@ -103,12 +103,29 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (!toProcess) {
console.error("Unknown category", group.category);
if (!group.pages) {
if (!group.subsections && !group.pages) {
group.pages = [];
}
continue;
}
if (group.subsections) {
// Listed pages keep their per-subsection order.
for (const subsection of group.subsections) {
for (const page of subsection.pages) {
if (!toProcess.delete(page)) {
console.error("Found unreferenced demo", page);
}
}
}
// Any remaining pages land in a trailing "Other" subsection.
const leftover = Array.from(toProcess).sort();
if (leftover.length) {
group.subsections.push({ header: "Other", pages: leftover });
}
continue;
}
// Any pre-defined groups will not be sorted.
if (group.pages) {
for (const page of group.pages) {
+11
View File
@@ -62,6 +62,17 @@ Use `sidebar.js` when a page needs a visible section, section header, or determi
- New categories without a sidebar entry are appended by the generator with their category name as the header.
- If a listed page does not exist, the generator logs an error during `gather-gallery-pages`.
### Subsections
A section can group its pages under named subsections instead of one flat list. Use this for large categories where related pages should sit together.
- `subsections` is an array of `{ header, pages }`. It is mutually exclusive with a flat `pages` array on the same group.
- Each subsection `header` is a non-collapsible label rendered inside the section's expansion panel; the section stays the only collapsible level.
- Listed pages keep their per-subsection order.
- Any pages found in the category but not listed in a subsection are collected into a generated `Other` subsection, appended alphabetically. The `Other` subsection is omitted when there are no leftovers.
- A listed page that does not exist still logs an error during `gather-gallery-pages`.
- Use sentence case for subsection headers and follow the content standards below.
## Markdown Pages
Use markdown pages for explanations, design guidance, API notes, and copy standards.
+164 -9
View File
@@ -10,6 +10,10 @@ import {
mdiViewDashboard,
} from "@mdi/js";
// A group may list its pages flat in `pages`, or group them under named
// `subsections`. The two are mutually exclusive. Listed pages keep their order;
// any pages found in the category but not listed are appended alphabetically
// (to a generated "Other" subsection when the group uses subsections).
export default [
{
// This section has no header and so all page links are shown directly in the sidebar
@@ -27,31 +31,162 @@ export default [
category: "components",
icon: mdiPuzzle,
header: "Components",
subsections: [
{
header: "Form and selectors",
pages: [
"ha-form",
"ha-selector",
"ha-select-box",
"ha-input",
"ha-textarea",
],
},
{
header: "Controls and sliders",
pages: [
"ha-button",
"ha-control-button",
"ha-progress-button",
"ha-switch",
"ha-control-switch",
"ha-slider",
"ha-control-slider",
"ha-control-circular-slider",
"ha-control-number-buttons",
"ha-control-select",
"ha-control-select-menu",
"ha-hs-color-picker",
],
},
{
header: "Overlays",
pages: [
"ha-dialog",
"ha-dialogs",
"ha-adaptive-dialog",
"ha-adaptive-popover",
"ha-dropdown",
"ha-tooltip",
],
},
{
header: "Lists and disclosure",
pages: ["ha-list", "ha-expansion-panel", "ha-faded"],
},
{
header: "Feedback and status",
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
},
{
header: "Labels and text",
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
},
],
},
{
category: "lovelace",
icon: mdiViewDashboard,
// Label for in the sidebar
header: "Dashboards",
// Specify order of pages. Any pages in the category folder but not listed here will
// automatically be added after the pages listed here.
pages: ["introduction"],
subsections: [
{
header: "Introduction",
pages: ["introduction"],
},
{
header: "Entity cards",
pages: [
"entities-card",
"entity-button-card",
"entity-filter-card",
"glance-card",
"tile-card",
"area-card",
],
},
{
header: "Picture cards",
pages: [
"picture-card",
"picture-elements-card",
"picture-entity-card",
"picture-glance-card",
],
},
{
header: "Domain cards",
pages: [
"light-card",
"thermostat-card",
"alarm-panel-card",
"gauge-card",
"plant-card",
"map-card",
"media-control-card",
"media-player-row",
],
},
{
header: "Layout and utility",
pages: [
"grid-and-stack-card",
"conditional-card",
"iframe-card",
"markdown-card",
"todo-list-card",
],
},
],
},
{
category: "more-info",
icon: mdiInformationOutline,
header: "More Info dialogs",
subsections: [
{
header: "Climate and water",
pages: ["climate", "humidifier", "water-heater", "fan"],
},
{
header: "Covers and access",
pages: ["cover", "lock", "lawn-mower", "vacuum"],
},
{
header: "Lighting",
pages: ["light", "scene"],
},
{
header: "Media",
pages: ["media-player"],
},
{
header: "Inputs and values",
pages: ["input-number", "input-text", "number", "timer"],
},
{
header: "System",
pages: ["update"],
},
],
},
{
category: "automation",
icon: mdiRobot,
header: "Automation",
pages: [
"editor-trigger",
"editor-condition",
"editor-action",
"trace",
"trace-timeline",
subsections: [
{
header: "Editors",
pages: ["editor-trigger", "editor-condition", "editor-action"],
},
{
header: "Descriptions",
pages: ["describe-trigger", "describe-condition", "describe-action"],
},
{
header: "Traces",
pages: ["trace", "trace-timeline"],
},
],
},
{
@@ -64,6 +199,26 @@ export default [
category: "date-time",
icon: mdiCalendarClock,
header: "Date and Time",
subsections: [
{
header: "Date",
pages: ["date"],
},
{
header: "Time",
pages: ["time", "time-seconds", "time-weekday"],
},
{
header: "Combined",
pages: [
"date-time",
"date-time-numeric",
"date-time-seconds",
"date-time-short",
"date-time-short-year",
],
},
],
},
{
category: "misc",
+60 -20
View File
@@ -40,15 +40,26 @@ interface GalleryPage {
demo?: unknown;
}
interface GallerySidebarSubsection {
header: string;
pages: string[];
}
interface GallerySidebarGroup {
category: string;
header?: string;
icon?: string;
pages: string[];
pages?: string[];
subsections?: GallerySidebarSubsection[];
}
const groupPages = (group: GallerySidebarGroup): string[] =>
group.subsections
? group.subsections.flatMap((subsection) => subsection.pages)
: (group.pages ?? []);
const GALLERY_SIDEBAR = SIDEBAR as GallerySidebarGroup[];
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${GALLERY_SIDEBAR[0].pages[0]}`;
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${groupPages(GALLERY_SIDEBAR[0])[0]}`;
const mql = matchMedia("(prefers-color-scheme: dark)");
@@ -284,26 +295,15 @@ class HaGallery extends LitElement {
const sidebar: unknown[] = [];
for (const group of GALLERY_SIDEBAR) {
const links: unknown[] = [];
const expanded = group.pages.some(
const expanded = groupPages(group).some(
(page) => this._page === `${group.category}/${page}`
);
for (const page of group.pages) {
const key = `${group.category}/${page}`;
if (!(key in PAGES)) {
console.error("Undefined page referenced in sidebar.js:", key);
continue;
}
links.push(
this._renderPageLink(
key,
PAGES[key].metadata.title || page,
group.header ? undefined : "main-navigation",
group.header ? undefined : group.icon
const content = group.subsections
? group.subsections.map((subsection) =>
this._renderSidebarSubsection(group, subsection)
)
);
}
: this._renderPageLinks(group, group.pages ?? []);
sidebar.push(
group.header
@@ -321,16 +321,46 @@ class HaGallery extends LitElement {
.path=${group.icon}
></ha-svg-icon>`
: nothing}
${links}
${content}
</ha-expansion-panel>
`
: links
: content
);
}
return sidebar;
}
private _renderSidebarSubsection(
group: GallerySidebarGroup,
subsection: GallerySidebarSubsection
) {
return html`
<div class="gallery-sidebar-subheader">${subsection.header}</div>
${this._renderPageLinks(group, subsection.pages)}
`;
}
private _renderPageLinks(group: GallerySidebarGroup, pages: string[]) {
const links: unknown[] = [];
for (const page of pages) {
const key = `${group.category}/${page}`;
if (!(key in PAGES)) {
console.error("Undefined page referenced in sidebar.js:", key);
continue;
}
links.push(
this._renderPageLink(
key,
PAGES[key].metadata.title || page,
group.header ? undefined : "main-navigation",
group.header ? undefined : group.icon
)
);
}
return links;
}
private _renderPageLink(
page: string,
title: string,
@@ -585,6 +615,16 @@ class HaGallery extends LitElement {
width: var(--ha-sidebar-expanded-section-item-width, 248px);
}
.gallery-sidebar-subheader {
margin: var(--ha-space-2) var(--ha-space-4) var(--ha-space-1);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.gallery-sidebar-icon,
.gallery-nav-item ha-svg-icon[slot="start"] {
color: var(--sidebar-icon-color);
+1 -1
View File
@@ -159,7 +159,7 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.4.1",
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
+76 -47
View File
@@ -162,35 +162,40 @@ export function generateStatisticsChartData(
// endTime is "now" and client time is not in sync with server time.
return;
}
statDataSets.forEach((d, i) => {
if (chartType === "line") {
if (
prevEndTime &&
prevValues &&
prevEndTime.getTime() !== start.getTime()
) {
const isLineChart = chartType === "line";
// For bar charts, optionally center the bar within its time range. The
// centered time is shared by every series of this data point.
const barTime =
!isLineChart && centerBars
? new Date((start.getTime() + end.getTime()) / 2)
: start;
// Whether a gap needs to be drawn before this data point (line charts).
const drawGap =
isLineChart &&
!!prevEndTime &&
!!prevValues &&
prevEndTime.getTime() !== start.getTime();
for (let i = 0; i < statDataSets.length; i++) {
const d = statDataSets[i];
const dataValue = dataValues[i];
if (isLineChart) {
if (drawGap) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
d.data!.push([prevEndTime!, ...prevValues![i]!]);
d.data!.push([prevEndTime!, null]);
}
d.data!.push([start, ...dataValues[i]!]);
d.data!.push([start, ...dataValue!]);
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
// the last element. For regular rows it's [value]. Same call works.
trackY(dataValues[i][dataValues[i].length - 1]);
trackY(dataValue[dataValue.length - 1]);
} else {
let time = start;
if (centerBars) {
// If centering bars, set the time to the midpoint between start and end instead
// of the start time.
time = new Date((start.getTime() + end.getTime()) / 2);
}
// Data value should always be a scalar for bar charts. Pass in
// real start time as extra value to allow formatting tooltip.
d.data!.push([time, dataValues[i][0]!, start, end]);
trackY(dataValues[i][0]);
d.data!.push([barTime, dataValue[0]!, start, end]);
trackY(dataValue[0]);
}
});
}
prevValues = dataValues;
prevEndTime = limit;
};
@@ -314,44 +319,68 @@ export function generateStatisticsChartData(
}
});
let prevDate: Date | null = null;
let prevStart: number | null = null;
// Process chart data.
let firstSum: number | null | undefined = null;
stats.forEach((stat) => {
// The per-type branch decisions in the inner loop are invariant across all
// stats of this statistic, so classify each type once up front.
// kind: 0 = sum (cumulative diff), 1 = band-top ([diff, top]), 2 = plain.
const SUM_KIND = 0;
const BAND_KIND = 1;
const PLAIN_KIND = 2;
const bandBottomHidden = hiddenStats.has(`${statistic_id}-${bandBottom}`);
const isLine = chartType === "line";
const typeKinds = statTypes.map((type) => {
if (type === "sum") {
return SUM_KIND;
}
if (type === bandTop && isLine && drawBands && !bandBottomHidden) {
return BAND_KIND;
}
return PLAIN_KIND;
});
const numTypes = statTypes.length;
const statHidden = hiddenStats.has(statistic_id);
for (const stat of stats) {
// Skip consecutive stats that share the same start time. Compare the raw
// numeric start so the dedup actually fires (a `Date` reference compare
// never would) and so we skip allocating a `Date` on the dropped path.
if (prevStart === stat.start) {
continue;
}
prevStart = stat.start;
const startDate = new Date(stat.start);
const endDate = new Date(stat.end);
if (prevDate === startDate) {
return;
}
prevDate = startDate;
const dataValues: (number | null)[][] = [];
statTypes.forEach((type) => {
for (let t = 0; t < numTypes; t++) {
const type = statTypes[t];
const val: (number | null)[] = [];
if (type === "sum") {
if (firstSum === null || firstSum === undefined) {
val.push(0);
firstSum = stat.sum;
} else {
val.push((stat.sum || 0) - firstSum);
switch (typeKinds[t]) {
case SUM_KIND:
if (firstSum === null || firstSum === undefined) {
val.push(0);
firstSum = stat.sum;
} else {
val.push((stat.sum || 0) - firstSum);
}
break;
case BAND_KIND: {
const top = stat[bandTop] || 0;
val.push(Math.abs(top - (stat[bandBottom] || 0)));
val.push(top);
break;
}
} else if (
type === bandTop &&
chartType === "line" &&
drawBands &&
!hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
const top = stat[bandTop] || 0;
val.push(Math.abs(top - (stat[bandBottom] || 0)));
val.push(top);
} else {
val.push(stat[type] ?? null);
default:
val.push(stat[type] ?? null);
}
dataValues.push(val);
});
if (!hiddenStats.has(statistic_id)) {
}
if (!statHidden) {
pushData(startDate, endDate, endTime, dataValues);
}
});
}
// For line charts, close out the last stat segment at prevEndTime
const lastEndTime = prevEndTime;
+20 -16
View File
@@ -1121,14 +1121,12 @@ const getSummedDataPartial = (
const timestamps = new Set<number>();
Object.entries(statIds).forEach(([key, subStatIds]) => {
const totalStats: Record<number, number> = {};
const sets: Record<string, Record<number, number>> = {};
let sum = 0;
subStatIds!.forEach((id) => {
const stats = compare ? data.statsCompare[id] : data.stats[id];
if (!stats) {
return;
}
const set = {};
stats.forEach((stat) => {
if (stat.change === null || stat.change === undefined) {
return;
@@ -1139,7 +1137,6 @@ const getSummedDataPartial = (
stat.start in totalStats ? totalStats[stat.start] + val : val;
timestamps.add(stat.start);
});
sets[id] = set;
});
summedData[key] = totalStats;
summedData.total[key] = sum;
@@ -1190,6 +1187,13 @@ const computeConsumptionDataPartial = (
},
};
const fromGrid = data.from_grid;
const toGrid = data.to_grid;
const solarData = data.solar;
const toBattery = data.to_battery;
const fromBattery = data.from_battery;
const total = outData.total;
data.timestamps.forEach((t) => {
const {
grid_to_battery,
@@ -1201,29 +1205,29 @@ const computeConsumptionDataPartial = (
solar_to_battery,
solar_to_grid,
} = computeConsumptionSingle({
from_grid: data.from_grid && (data.from_grid[t] ?? 0),
to_grid: data.to_grid && (data.to_grid[t] ?? 0),
solar: data.solar && (data.solar[t] ?? 0),
to_battery: data.to_battery && (data.to_battery[t] ?? 0),
from_battery: data.from_battery && (data.from_battery[t] ?? 0),
from_grid: fromGrid && (fromGrid[t] ?? 0),
to_grid: toGrid && (toGrid[t] ?? 0),
solar: solarData && (solarData[t] ?? 0),
to_battery: toBattery && (toBattery[t] ?? 0),
from_battery: fromBattery && (fromBattery[t] ?? 0),
});
outData.used_total[t] = used_total;
outData.total.used_total += used_total;
total.used_total += used_total;
outData.grid_to_battery[t] = grid_to_battery;
outData.total.grid_to_battery += grid_to_battery;
total.grid_to_battery += grid_to_battery;
outData.battery_to_grid![t] = battery_to_grid;
outData.total.battery_to_grid += battery_to_grid;
total.battery_to_grid += battery_to_grid;
outData.used_battery![t] = used_battery;
outData.total.used_battery += used_battery;
total.used_battery += used_battery;
outData.used_grid![t] = used_grid;
outData.total.used_grid += used_grid;
total.used_grid += used_grid;
outData.used_solar![t] = used_solar;
outData.total.used_solar += used_solar;
total.used_solar += used_solar;
outData.solar_to_battery[t] = solar_to_battery;
outData.total.solar_to_battery += solar_to_battery;
total.solar_to_battery += solar_to_battery;
outData.solar_to_grid[t] = solar_to_grid;
outData.total.solar_to_grid += solar_to_grid;
total.solar_to_grid += solar_to_grid;
});
return outData;
+60 -50
View File
@@ -164,60 +164,70 @@ export class HistoryStream {
? (new Date().getTime() - 60 * 60 * this.hoursToShow * 1000) / 1000
: undefined;
const newHistory: HistoryStates = {};
for (const entityId of Object.keys(this.combinedHistory)) {
newHistory[entityId] = [];
}
for (const entityId of Object.keys(streamMessage.states)) {
newHistory[entityId] = [];
}
for (const entityId of Object.keys(newHistory)) {
if (
entityId in this.combinedHistory &&
entityId in streamMessage.states
) {
// Build the union of entity ids (existing first, then new ones) in a
// single pass and process each entity inline. The per-entity slot is
// always assigned below before being read, so there is no need to
// pre-seed every key with an empty array first.
const streamStates = streamMessage.states;
const processEntity = (entityId: string) => {
const inCombined = entityId in this.combinedHistory;
const inStream = entityId in streamStates;
if (inCombined && inStream) {
const entityCombinedHistory = this.combinedHistory[entityId];
const lastEntityCombinedHistory =
entityCombinedHistory[entityCombinedHistory.length - 1];
newHistory[entityId] = entityCombinedHistory.concat(
streamMessage.states[entityId]
streamStates[entityId]
);
if (
streamMessage.states[entityId][0].lu < lastEntityCombinedHistory.lu
) {
if (streamStates[entityId][0].lu < lastEntityCombinedHistory.lu) {
// If the history is out of order we have to sort it.
newHistory[entityId] = newHistory[entityId].sort(
(a, b) => a.lu - b.lu
);
}
} else if (entityId in this.combinedHistory) {
} else if (inCombined) {
newHistory[entityId] = this.combinedHistory[entityId];
} else {
newHistory[entityId] = streamMessage.states[entityId];
newHistory[entityId] = streamStates[entityId];
return;
}
// Remove old history
if (purgeBeforePythonTime && entityId in this.combinedHistory) {
const expiredStates = newHistory[entityId].filter(
(state) => state.lu < purgeBeforePythonTime
);
if (!expiredStates.length) {
continue;
// Remove old history (only entities present in combinedHistory reach
// here without an early return).
if (purgeBeforePythonTime) {
// Single pass: split into kept (lu >= cutoff, preserving order) and
// track the last expired state (lu < cutoff) without allocating a
// second array.
const states = newHistory[entityId];
const kept: EntityHistoryState[] = [];
let lastExpiredState: EntityHistoryState | undefined;
for (const state of states) {
if (state.lu < purgeBeforePythonTime) {
lastExpiredState = state;
} else {
kept.push(state);
}
}
newHistory[entityId] = newHistory[entityId].filter(
(state) => state.lu >= purgeBeforePythonTime
);
if (
newHistory[entityId].length &&
newHistory[entityId][0].lu === purgeBeforePythonTime
) {
continue;
if (!lastExpiredState) {
return;
}
newHistory[entityId] = kept;
if (kept.length && kept[0].lu === purgeBeforePythonTime) {
return;
}
// Update the first entry to the start time state
// as we need to preserve the start time state and
// only expire the rest of the history as it ages.
const lastExpiredState = expiredStates[expiredStates.length - 1];
lastExpiredState.lu = purgeBeforePythonTime;
delete lastExpiredState.lc;
newHistory[entityId].unshift(lastExpiredState);
kept.unshift(lastExpiredState);
}
};
for (const entityId of Object.keys(this.combinedHistory)) {
processEntity(entityId);
}
for (const entityId of Object.keys(streamStates)) {
if (!(entityId in this.combinedHistory)) {
processEntity(entityId);
}
}
this.combinedHistory = newHistory;
@@ -381,16 +391,18 @@ const processLineChartEntities = (
): LineChartUnit => {
const data: LineChartEntity[] = [];
Object.keys(entities).forEach((entityId) => {
const entityIds = Object.keys(entities);
entityIds.forEach((entityId) => {
const states = entities[entityId];
const first: EntityHistoryState = states[0];
const domain = computeDomain(entityId);
const useLastUpdated = DOMAINS_USE_LAST_UPDATED.includes(domain);
const processedStates: LineChartState[] = [];
for (const state of states) {
let processedState: LineChartState;
if (DOMAINS_USE_LAST_UPDATED.includes(domain)) {
if (useLastUpdated) {
processedState = {
state: state.s,
last_changed: state.lu * 1000,
@@ -412,13 +424,11 @@ const processLineChartEntities = (
};
}
const len = processedStates.length;
if (
processedStates.length > 1 &&
equalState(
processedState,
processedStates[processedStates.length - 1]
) &&
equalState(processedState, processedStates[processedStates.length - 2])
len > 1 &&
equalState(processedState, processedStates[len - 1]) &&
equalState(processedState, processedStates[len - 2])
) {
continue;
}
@@ -444,11 +454,17 @@ const processLineChartEntities = (
return {
unit,
device_class,
identifier: Object.keys(entities).join(""),
identifier: entityIds.join(""),
data,
};
};
const SPECIAL_DOMAIN_CLASSES: Record<string, string | undefined> = {
climate: "temperature",
humidifier: "humidity",
water_heater: "temperature",
};
const NUMERICAL_DOMAINS = ["counter", "input_number", "number"];
const isNumericFromDomain = (domain: string) =>
@@ -593,14 +609,8 @@ export const computeHistory = (
}[domain];
}
const specialDomainClasses = {
climate: "temperature",
humidifier: "humidity",
water_heater: "temperature",
};
const deviceClass: string | undefined =
specialDomainClasses[domain] ||
SPECIAL_DOMAIN_CLASSES[domain] ||
(currentState?.attributes || numericStateFromHistory?.a)?.device_class;
const key = computeGroupKey(unit, deviceClass, splitDeviceClasses);
@@ -304,38 +304,34 @@ function formatTooltip(
: nothing}`;
}
function getDatapointX(datapoint: NonNullable<LineSeriesOption["data"]>[0]) {
const item =
datapoint && typeof datapoint === "object" && "value" in datapoint
? datapoint
: { value: datapoint };
return Number(item.value?.[0]);
}
export function fillLineGaps(datasets: LineSeriesOption[]) {
const buckets = Array.from(
new Set(
datasets
.map((dataset) =>
dataset.data!.map((datapoint) => getDatapointX(datapoint))
)
.flat()
)
).sort((a, b) => a - b);
// Single pass per datapoint: normalise it to a LineDataItemOption, compute
// its x once, collect every x into the shared bucket set, and build each
// dataset's lookup map at the same time. This avoids re-deriving x and
// re-discriminating the tuple/object shape in a separate pass.
const bucketSet = new Set<number>();
const dataMaps: Map<number, LineDataItemOption>[] = [];
datasets.forEach((dataset) => {
for (const dataset of datasets) {
const dataMap = new Map<number, LineDataItemOption>();
dataset.data!.forEach((datapoint) => {
for (const datapoint of dataset.data!) {
const item: LineDataItemOption =
datapoint && typeof datapoint === "object" && "value" in datapoint
? datapoint
: ({ value: datapoint } as LineDataItemOption);
const x = getDatapointX(datapoint);
const x = Number(item.value?.[0]);
bucketSet.add(x);
if (!Number.isNaN(x)) {
dataMap.set(x, item);
}
});
}
dataMaps.push(dataMap);
}
const buckets = Array.from(bucketSet).sort((a, b) => a - b);
datasets.forEach((dataset, index) => {
const dataMap = dataMaps[index];
dataset.data = buckets.map((bucket) => dataMap.get(bucket) ?? [bucket, 0]);
});
@@ -0,0 +1,241 @@
import type { BarSeriesOption } from "echarts/charts";
import { computeYAxisFractionDigits } from "../../../../components/chart/y-axis-fraction-digits";
import { fillDataGapsAndRoundCaps } from "../../../../components/chart/round-caps";
import type {
EnergyData,
GasSourceTypeEnergyPreference,
} from "../../../../data/energy";
import { getSuggestedPeriod } from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import type { HomeAssistant } from "../../../../types";
import { getEnergyColor } from "./common/color";
import {
type EnergyDataPoint,
getCompareTransform,
} from "./common/energy-chart-options";
export interface EnergyGasGraphDataParams {
hass: HomeAssistant;
energyData: EnergyData;
computedStyles: CSSStyleDeclaration;
/** Current time, injected so the transform is deterministic. */
now: Date;
}
export interface EnergyGasGraphData {
chartData: BarSeriesOption[];
start: Date;
end: Date;
compareStart?: Date;
compareEnd?: Date;
unit?: string;
total?: number;
yAxisFractionDigits: number;
}
/**
* Transforms an energy collection update (`EnergyData` + config + environment)
* into the gas graph card's chart series and derived state. Pure data
* processing: every environment read (current time, theme style, hass) is
* injected so the transform is deterministic and benchmarkable.
*/
export function generateEnergyGasGraphData(
params: EnergyGasGraphDataParams
): EnergyGasGraphData {
const { hass, energyData, computedStyles, now } = params;
const start = energyData.start;
const end = energyData.end || now;
const compareStart = energyData.startCompare;
const compareEnd = energyData.endCompare;
const gasSources: GasSourceTypeEnergyPreference[] =
energyData.prefs.energy_sources.filter(
(source) => source.type === "gas"
) as GasSourceTypeEnergyPreference[];
const unit = energyData.gasUnit;
const datasets: BarSeriesOption[] = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number) => {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
};
// `compareTransform` and `period` depend only on start/end/compareStart,
// which are identical for both the compare and main passes. Compute them
// once here instead of recomputing (and re-allocating the transform
// closure) inside each processDataSet call.
const compareTransform = getCompareTransform(start, compareStart!);
const period = getSuggestedPeriod(start, end);
if (energyData.statsCompare) {
datasets.push(
...processDataSet(
hass,
compareTransform,
period,
energyData.statsCompare,
energyData.statsMetadata,
gasSources,
computedStyles,
trackY,
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: gas` so it doesn't take up space yet
const firstId = gasSources[0]?.stat_energy_from ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "gas",
data: [],
});
}
datasets.push(
...processDataSet(
hass,
compareTransform,
period,
energyData.stats,
energyData.statsMetadata,
gasSources,
computedStyles,
trackY
)
);
fillDataGapsAndRoundCaps(datasets);
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
const chartData = datasets;
const total = processTotal(energyData.stats, gasSources);
return {
chartData,
start,
end,
compareStart,
compareEnd,
unit,
total,
yAxisFractionDigits,
};
}
function processTotal(
statistics: Statistics,
gasSources: GasSourceTypeEnergyPreference[]
) {
return gasSources.reduce(
(sum, source) =>
sum +
(source.stat_energy_from in statistics
? statistics[source.stat_energy_from].reduce(
(acc, curr) => acc + (curr.change || 0),
0
)
: 0),
0
);
}
function processDataSet(
hass: HomeAssistant,
compareTransform: (ts: Date) => Date,
period: ReturnType<typeof getSuggestedPeriod>,
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
gasSources: GasSourceTypeEnergyPreference[],
computedStyles: CSSStyleDeclaration,
trackY: (v: number) => void,
compare = false
) {
const data: BarSeriesOption[] = [];
// `center` (sub-daily midpoint) and the active compare transform depend only
// on the call-level `period`/`compare` args, so they are loop-invariant.
// Hoist them once and inline computeStatMidpoint below, choosing the branch
// from these two booleans, to avoid a per-point function call, a per-point
// `center` recompute and a per-point `compare ? … : undefined` ternary in the
// hottest loop. The arithmetic and addition order are kept identical so the
// resulting timestamps are bit-identical to computeStatMidpoint.
const center = period === "hour" || period === "5minute";
const transform = compare ? compareTransform : undefined;
gasSources.forEach((source, idx) => {
const statId = source.stat_energy_from;
let prevStart: number | null = null;
const gasConsumptionData: BarSeriesOption["data"] = [];
// Process gas consumption data.
if (statId in statistics) {
const stats = statistics[statId];
for (const point of stats) {
const change = point.change;
if (change === null || change === undefined || change === 0) {
continue;
}
const pointStart = point.start;
if (prevStart === pointStart) {
continue;
}
let midpoint: number;
if (center) {
midpoint = transform
? (transform(new Date(pointStart)).getTime() +
transform(new Date(point.end)).getTime()) /
2
: (pointStart + point.end) / 2;
} else {
midpoint = transform
? transform(new Date(pointStart)).getTime()
: pointStart;
}
const dataPoint: EnergyDataPoint = [midpoint, change, pointStart];
gasConsumptionData.push(dataPoint);
trackY(change);
prevStart = pointStart;
}
}
data.push({
type: "bar",
cursor: "default",
id: compare ? "compare-" + statId : statId,
name:
source.name ||
getStatisticLabel(hass, statId, statisticsMetaData[statId]),
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
computedStyles,
hass.themes.darkMode,
false,
compare,
"--energy-gas-color",
idx
),
},
color: getEnergyColor(
computedStyles,
hass.themes.darkMode,
true,
compare,
"--energy-gas-color",
idx
),
data: gasConsumptionData,
stack: compare ? "compare-gas" : "gas",
});
});
return data;
}
@@ -0,0 +1,375 @@
import type { BarSeriesOption, LineSeriesOption } from "echarts/charts";
import { computeYAxisFractionDigits } from "../../../../components/chart/y-axis-fraction-digits";
import type {
EnergyData,
EnergySolarForecasts,
SolarSourceTypeEnergyPreference,
} from "../../../../data/energy";
import { getSuggestedPeriod } from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import type { HomeAssistant } from "../../../../types";
import { getEnergyColor } from "./common/color";
import {
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCompareTransform,
} from "./common/energy-chart-options";
export interface EnergySolarGraphDataParams {
hass: HomeAssistant;
energyData: EnergyData;
/**
* Solar forecasts resolved by the caller (the async fetch stays in the
* component); `undefined` when no forecast data is available.
*/
forecasts: EnergySolarForecasts | undefined;
computedStyles: CSSStyleDeclaration;
/** Fallback for `energyData.end` (the component uses `endOfToday()`). */
now: Date;
}
export interface EnergySolarGraphData {
chartData: (BarSeriesOption | LineSeriesOption)[];
total: number;
start: Date;
end: Date;
compareStart?: Date;
compareEnd?: Date;
yAxisFractionDigits: number;
}
/**
* Transforms an `EnergyData` collection update into the ECharts series and
* derived state for `hui-energy-solar-graph-card`. Pure data processing: all
* environment inputs (current time, theme style, hass, resolved forecasts) are
* injected so the transform is deterministic and benchmarkable.
*/
export function generateEnergySolarGraphData(
params: EnergySolarGraphDataParams
): EnergySolarGraphData {
const { hass, energyData, forecasts, computedStyles, now } = params;
const start = energyData.start;
const end = energyData.end || now;
const compareStart = energyData.startCompare;
const compareEnd = energyData.endCompare;
const solarSources: SolarSourceTypeEnergyPreference[] =
energyData.prefs.energy_sources.filter(
(source) => source.type === "solar"
) as SolarSourceTypeEnergyPreference[];
// Both processDataSet calls below receive identical start/end/compareStart,
// so the compare transform and the suggested period are loop-invariant across
// them; compute each once instead of rebuilding the date-fns-heavy values per
// call.
const compareTransform = getCompareTransform(start, compareStart!);
const period = getSuggestedPeriod(start, end);
const datasets: (BarSeriesOption | LineSeriesOption)[] = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number) => {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
};
if (energyData.statsCompare) {
datasets.push(
...processDataSet(
hass,
compareTransform,
period,
energyData.statsCompare,
energyData.statsMetadata,
solarSources,
computedStyles,
trackY,
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: solar` so it doesn't take up space yet
const firstId = solarSources[0]?.stat_energy_from ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "solar",
data: [],
});
}
datasets.push(
...processDataSet(
hass,
compareTransform,
period,
energyData.stats,
energyData.statsMetadata,
solarSources,
computedStyles,
trackY
)
);
fillDataGapsAndRoundCaps(datasets as BarSeriesOption[]);
if (forecasts) {
datasets.push(
...processForecast(
hass,
energyData.statsMetadata,
forecasts,
solarSources,
computedStyles.getPropertyValue("--primary-text-color"),
energyData.start,
energyData.end,
trackY
)
);
}
return {
chartData: datasets,
total: processTotal(energyData.stats, solarSources),
start,
end,
compareStart,
compareEnd,
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax),
};
}
function processTotal(
statistics: Statistics,
solarSources: SolarSourceTypeEnergyPreference[]
) {
return solarSources.reduce(
(sum, source) =>
sum +
(source.stat_energy_from in statistics
? statistics[source.stat_energy_from].reduce(
(acc, curr) => acc + (curr.change || 0),
0
)
: 0),
0
);
}
function processDataSet(
hass: HomeAssistant,
compareTransform: (ts: Date) => Date,
period: "5minute" | "hour" | "day" | "month",
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
solarSources: SolarSourceTypeEnergyPreference[],
computedStyles: CSSStyleDeclaration,
trackY: (v: number) => void,
compare = false
) {
const data: BarSeriesOption[] = [];
// The compare transform only applies to the compare dataset; for the main
// dataset computeStatMidpoint receives `undefined` (loop-invariant).
const midpointTransform = compare ? compareTransform : undefined;
// `period` is fixed for the whole call, so resolve the midpoint mode once
// instead of re-deriving it (and re-comparing the period string) per point.
const center = period === "hour" || period === "5minute";
// hass.themes.darkMode is read twice per source below; cache it once.
const darkMode = hass.themes.darkMode;
solarSources.forEach((source, idx) => {
let prevStart: number | null = null;
const solarProductionData: BarSeriesOption["data"] = [];
// Process solar production data.
if (source.stat_energy_from in statistics) {
const stats = statistics[source.stat_energy_from];
for (const point of stats) {
const change = point.change;
// change is `number | null | undefined`; `== null` matches both null
// and undefined in one comparison.
if (change == null || change === 0) {
continue;
}
const pointStart = point.start;
if (prevStart === pointStart) {
continue;
}
// Inlined computeStatMidpoint with the period branch hoisted out of the
// loop. Arithmetic and operand order are preserved exactly.
let midpoint: number;
if (!center) {
midpoint = midpointTransform
? midpointTransform(new Date(pointStart)).getTime()
: pointStart;
} else if (midpointTransform) {
midpoint =
(midpointTransform(new Date(pointStart)).getTime() +
midpointTransform(new Date(point.end)).getTime()) /
2;
} else {
midpoint = (pointStart + point.end) / 2;
}
const dataPoint: EnergyDataPoint = [midpoint, change, pointStart];
solarProductionData.push(dataPoint);
trackY(change);
prevStart = pointStart;
}
}
data.push({
type: "bar",
cursor: "default",
id: compare
? "compare-" + source.stat_energy_from
: source.stat_energy_from,
name: hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.production",
{
name:
source.name ||
getStatisticLabel(
hass,
source.stat_energy_from,
statisticsMetaData[source.stat_energy_from]
),
}
),
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
computedStyles,
darkMode,
false,
compare,
"--energy-solar-color",
idx
),
},
color: getEnergyColor(
computedStyles,
darkMode,
true,
compare,
"--energy-solar-color",
idx
),
data: solarProductionData,
stack: compare ? "compare" : "solar",
});
});
return data;
}
function processForecast(
hass: HomeAssistant,
statisticsMetaData: Record<string, StatisticsMetaData>,
forecasts: EnergySolarForecasts,
solarSources: SolarSourceTypeEnergyPreference[],
borderColor: string,
start: Date,
end: Date | undefined,
trackY: (v: number) => void
) {
const data: LineSeriesOption[] = [];
// Recompute the period here intentionally — do NOT reuse the hoisted period
// from the entry function. processForecast receives the raw `energyData.end`
// (which may be undefined) whereas the entry function uses `energyData.end ||
// now`, so the two periods can legitimately differ; collapsing them would
// change the forecast hour-flooring near an undefined end.
const period = getSuggestedPeriod(start, end);
// Process solar forecast data.
solarSources.forEach((source) => {
if (source.config_entry_solar_forecast) {
const forecastsData: Record<string, number> | undefined = {};
source.config_entry_solar_forecast.forEach((configEntryId) => {
const forecast = forecasts![configEntryId];
if (!forecast) {
return;
}
Object.entries(forecast.wh_hours).forEach(([date, value]) => {
const dateObj = new Date(date);
if (dateObj < start || (end && dateObj > end)) {
return;
}
if (period === "month") {
dateObj.setDate(1);
}
if (period === "month" || period === "day") {
dateObj.setHours(0, 0, 0, 0);
} else {
dateObj.setMinutes(0, 0, 0);
}
const time = dateObj.getTime();
if (time in forecastsData) {
forecastsData[time] += value;
} else {
forecastsData[time] = value;
}
});
});
if (forecastsData) {
const solarForecastData: LineSeriesOption["data"] = [];
// Only center forecast points for sub-daily periods to align with bars.
// Only start timestamps available, so estimate midpoint from the gap
// between the first two entries. Assumes uniform spacing.
let forecastOffset = 0;
if (period === "hour" || period === "5minute") {
const forecastTimes = Object.keys(forecastsData)
.map(Number)
.sort((a, b) => a - b);
forecastOffset =
forecastTimes.length >= 2
? (forecastTimes[1] - forecastTimes[0]) / 2
: 0;
}
for (const [time, value] of Object.entries(forecastsData)) {
const kWh = value / 1000;
solarForecastData.push([Number(time) + forecastOffset, kWh]);
trackY(kWh);
}
if (solarForecastData.length) {
data.push({
id: "forecast-" + source.stat_energy_from,
type: "line",
stack: "forecast",
name: hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.forecast",
{
name:
source.name ||
getStatisticLabel(
hass,
source.stat_energy_from,
statisticsMetaData[source.stat_energy_from]
),
}
),
step: false,
color: borderColor,
lineStyle: {
type: [7, 5],
width: 1.5,
},
symbol: "none",
data: solarForecastData,
});
}
}
}
});
return data;
}
@@ -6,36 +6,23 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption } from "echarts/charts";
import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
import { computeYAxisFractionDigits } from "../../../../components/chart/y-axis-fraction-digits";
import "../../../../components/ha-card";
import type {
EnergyData,
GasSourceTypeEnergyPreference,
} from "../../../../data/energy";
import type { EnergyData } from "../../../../data/energy";
import {
getEnergyDataCollection,
getSuggestedPeriod,
validateEnergyCollectionKey,
} from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import type { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyGasGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import {
computeStatMidpoint,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import { getCommonOptions } from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import { generateEnergyGasGraphData } from "./energy-gas-graph-data";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -193,173 +180,21 @@ export class HuiEnergyGasGraphCard
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
const gasSources: GasSourceTypeEnergyPreference[] =
energyData.prefs.energy_sources.filter(
(source) => source.type === "gas"
) as GasSourceTypeEnergyPreference[];
this._unit = energyData.gasUnit;
const datasets: BarSeriesOption[] = [];
const computedStyles = getComputedStyle(this);
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number) => {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
};
if (energyData.statsCompare) {
datasets.push(
...this._processDataSet(
energyData.statsCompare,
energyData.statsMetadata,
gasSources,
computedStyles,
trackY,
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: gas` so it doesn't take up space yet
const firstId = gasSources[0]?.stat_energy_from ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "gas",
data: [],
});
}
datasets.push(
...this._processDataSet(
energyData.stats,
energyData.statsMetadata,
gasSources,
computedStyles,
trackY
)
);
fillDataGapsAndRoundCaps(datasets);
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._total = this._processTotal(energyData.stats, gasSources);
}
private _processTotal(
statistics: Statistics,
gasSources: GasSourceTypeEnergyPreference[]
) {
return gasSources.reduce(
(sum, source) =>
sum +
(source.stat_energy_from in statistics
? statistics[source.stat_energy_from].reduce(
(acc, curr) => acc + (curr.change || 0),
0
)
: 0),
0
);
}
private _processDataSet(
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
gasSources: GasSourceTypeEnergyPreference[],
computedStyles: CSSStyleDeclaration,
trackY: (v: number) => void,
compare = false
) {
const data: BarSeriesOption[] = [];
const compareTransform = getCompareTransform(
this._start,
this._compareStart!
);
const period = getSuggestedPeriod(this._start, this._end);
gasSources.forEach((source, idx) => {
let prevStart: number | null = null;
const gasConsumptionData: BarSeriesOption["data"] = [];
// Process gas consumption data.
if (source.stat_energy_from in statistics) {
const stats = statistics[source.stat_energy_from];
for (const point of stats) {
if (
point.change === null ||
point.change === undefined ||
point.change === 0
) {
continue;
}
if (prevStart === point.start) {
continue;
}
const dataPoint: EnergyDataPoint = [
computeStatMidpoint(
point.start,
point.end,
period,
compare ? compareTransform : undefined
),
point.change,
point.start,
];
gasConsumptionData.push(dataPoint);
trackY(point.change);
prevStart = point.start;
}
}
data.push({
type: "bar",
cursor: "default",
id: compare
? "compare-" + source.stat_energy_from
: source.stat_energy_from,
name:
source.name ||
getStatisticLabel(
this.hass,
source.stat_energy_from,
statisticsMetaData[source.stat_energy_from]
),
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
false,
compare,
"--energy-gas-color",
idx
),
},
color: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
true,
compare,
"--energy-gas-color",
idx
),
data: gasConsumptionData,
stack: compare ? "compare-gas" : "gas",
});
const result = generateEnergyGasGraphData({
hass: this.hass,
energyData,
computedStyles: getComputedStyle(this),
now: endOfToday(),
});
return data;
this._start = result.start;
this._end = result.end;
this._compareStart = result.compareStart;
this._compareEnd = result.compareEnd;
this._unit = result.unit;
this._yAxisFractionDigits = result.yAxisFractionDigits;
this._chartData = result.chartData;
this._total = result.total;
}
static styles = css`
@@ -6,10 +6,8 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption, LineSeriesOption } from "echarts/charts";
import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
import { computeYAxisFractionDigits } from "../../../../components/chart/y-axis-fraction-digits";
import "../../../../components/ha-card";
import type {
EnergyData,
@@ -19,24 +17,16 @@ import type {
import {
getEnergyDataCollection,
getEnergySolarForecasts,
getSuggestedPeriod,
validateEnergyCollectionKey,
} from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import type { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergySolarGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import {
computeStatMidpoint,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import { getCommonOptions } from "./common/energy-chart-options";
import { generateEnergySolarGraphData } from "./energy-solar-graph-data";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -191,12 +181,6 @@ export class HuiEnergySolarGraphCard
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._compareStart = energyData.startCompare;
this._compareEnd = energyData.endCompare;
const solarSources: SolarSourceTypeEnergyPreference[] =
energyData.prefs.energy_sources.filter(
(source) => source.type === "solar"
@@ -213,282 +197,21 @@ export class HuiEnergySolarGraphCard
}
}
const datasets: (BarSeriesOption | LineSeriesOption)[] = [];
const computedStyles = getComputedStyle(this);
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number) => {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
};
if (energyData.statsCompare) {
datasets.push(
...this._processDataSet(
energyData.statsCompare,
energyData.statsMetadata,
solarSources,
computedStyles,
trackY,
true
)
);
} else {
// add empty dataset so compare bars are first
// `stack: solar` so it doesn't take up space yet
const firstId = solarSources[0]?.stat_energy_from ?? "placeholder";
datasets.push({
id: "compare-" + firstId,
type: "bar",
stack: "solar",
data: [],
});
}
datasets.push(
...this._processDataSet(
energyData.stats,
energyData.statsMetadata,
solarSources,
computedStyles,
trackY
)
);
fillDataGapsAndRoundCaps(datasets as BarSeriesOption[]);
if (forecasts) {
datasets.push(
...this._processForecast(
energyData.statsMetadata,
forecasts,
solarSources,
computedStyles.getPropertyValue("--primary-text-color"),
energyData.start,
energyData.end,
trackY
)
);
}
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._total = this._processTotal(energyData.stats, solarSources);
}
private _processTotal(
statistics: Statistics,
solarSources: SolarSourceTypeEnergyPreference[]
) {
return solarSources.reduce(
(sum, source) =>
sum +
(source.stat_energy_from in statistics
? statistics[source.stat_energy_from].reduce(
(acc, curr) => acc + (curr.change || 0),
0
)
: 0),
0
);
}
private _processDataSet(
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
solarSources: SolarSourceTypeEnergyPreference[],
computedStyles: CSSStyleDeclaration,
trackY: (v: number) => void,
compare = false
) {
const data: BarSeriesOption[] = [];
const compareTransform = getCompareTransform(
this._start,
this._compareStart!
);
const period = getSuggestedPeriod(this._start, this._end);
solarSources.forEach((source, idx) => {
let prevStart: number | null = null;
const solarProductionData: BarSeriesOption["data"] = [];
// Process solar production data.
if (source.stat_energy_from in statistics) {
const stats = statistics[source.stat_energy_from];
for (const point of stats) {
if (
point.change === null ||
point.change === undefined ||
point.change === 0
) {
continue;
}
if (prevStart === point.start) {
continue;
}
const dataPoint: EnergyDataPoint = [
computeStatMidpoint(
point.start,
point.end,
period,
compare ? compareTransform : undefined
),
point.change,
point.start,
];
solarProductionData.push(dataPoint);
trackY(point.change);
prevStart = point.start;
}
}
data.push({
type: "bar",
cursor: "default",
id: compare
? "compare-" + source.stat_energy_from
: source.stat_energy_from,
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.production",
{
name:
source.name ||
getStatisticLabel(
this.hass,
source.stat_energy_from,
statisticsMetaData[source.stat_energy_from]
),
}
),
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
false,
compare,
"--energy-solar-color",
idx
),
},
color: getEnergyColor(
computedStyles,
this.hass.themes.darkMode,
true,
compare,
"--energy-solar-color",
idx
),
data: solarProductionData,
stack: compare ? "compare" : "solar",
});
const result = generateEnergySolarGraphData({
hass: this.hass,
energyData,
forecasts,
computedStyles: getComputedStyle(this),
now: endOfToday(),
});
return data;
}
private _processForecast(
statisticsMetaData: Record<string, StatisticsMetaData>,
forecasts: EnergySolarForecasts,
solarSources: SolarSourceTypeEnergyPreference[],
borderColor: string,
start: Date,
end: Date | undefined,
trackY: (v: number) => void
) {
const data: LineSeriesOption[] = [];
const period = getSuggestedPeriod(start, end);
// Process solar forecast data.
solarSources.forEach((source) => {
if (source.config_entry_solar_forecast) {
const forecastsData: Record<string, number> | undefined = {};
source.config_entry_solar_forecast.forEach((configEntryId) => {
if (!forecasts![configEntryId]) {
return;
}
Object.entries(forecasts![configEntryId].wh_hours).forEach(
([date, value]) => {
const dateObj = new Date(date);
if (dateObj < start || (end && dateObj > end)) {
return;
}
if (period === "month") {
dateObj.setDate(1);
}
if (period === "month" || period === "day") {
dateObj.setHours(0, 0, 0, 0);
} else {
dateObj.setMinutes(0, 0, 0);
}
const time = dateObj.getTime();
if (time in forecastsData) {
forecastsData[time] += value;
} else {
forecastsData[time] = value;
}
}
);
});
if (forecastsData) {
const solarForecastData: LineSeriesOption["data"] = [];
// Only center forecast points for sub-daily periods to align with bars.
// Only start timestamps available, so estimate midpoint from the gap
// between the first two entries. Assumes uniform spacing.
let forecastOffset = 0;
if (period === "hour" || period === "5minute") {
const forecastTimes = Object.keys(forecastsData)
.map(Number)
.sort((a, b) => a - b);
forecastOffset =
forecastTimes.length >= 2
? (forecastTimes[1] - forecastTimes[0]) / 2
: 0;
}
for (const [time, value] of Object.entries(forecastsData)) {
const kWh = value / 1000;
solarForecastData.push([Number(time) + forecastOffset, kWh]);
trackY(kWh);
}
if (solarForecastData.length) {
data.push({
id: "forecast-" + source.stat_energy_from,
type: "line",
stack: "forecast",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_solar_graph.forecast",
{
name:
source.name ||
getStatisticLabel(
this.hass,
source.stat_energy_from,
statisticsMetaData[source.stat_energy_from]
),
}
),
step: false,
color: borderColor,
lineStyle: {
type: [7, 5],
width: 1.5,
},
symbol: "none",
data: solarForecastData,
});
}
}
}
});
return data;
this._start = result.start;
this._end = result.end;
this._compareStart = result.compareStart;
this._compareEnd = result.compareEnd;
this._yAxisFractionDigits = result.yAxisFractionDigits;
this._chartData = result.chartData;
this._total = result.total;
}
static styles = css`
@@ -41,6 +41,7 @@ import {
getCompareTransform,
} from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
const colorPropertyMap = {
to_grid: "--energy-grid-return-color",
@@ -86,6 +87,8 @@ export class HuiEnergyUsageGraphCard
@state() private _yAxisFractionDigits = 1;
@state() private _legendData?: CustomLegendOption["data"];
@state() private _start = startOfToday();
@state() private _end = endOfToday();
@@ -162,7 +165,8 @@ export class HuiEnergyUsageGraphCard
this.hass.config,
this._compareStart,
this._compareEnd,
this._yAxisFractionDigits
this._yAxisFractionDigits,
this._legendData
)}
chart-type="bar"
></ha-chart-base>
@@ -194,7 +198,8 @@ export class HuiEnergyUsageGraphCard
config: HassConfig,
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
yAxisFractionDigits: number,
legendData?: CustomLegendOption["data"]
): HaECOption => {
const commonOptions = getCommonOptions(
start,
@@ -217,6 +222,11 @@ export class HuiEnergyUsageGraphCard
: undefined;
const options: HaECOption = {
...commonOptions,
legend: {
show: this._config?.show_legend !== false,
type: "custom",
data: legendData,
},
tooltip: {
...commonOptions.tooltip,
formatter: (params: TopLevelFormatterParams) => {
@@ -432,9 +442,37 @@ export class HuiEnergyUsageGraphCard
fillDataGapsAndRoundCaps(datasets);
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._legendData = this._getLegendData(datasets);
this._total = this._processTotal(consumption);
}
private _getLegendData(
datasets: BarSeriesOption[]
): CustomLegendOption["data"] {
// Each main series gets a legend item, and its matching compare
// series (if any) is attached as a secondary id so toggling
// the legend item shows/hides both at once.
const compareIds = new Set(
datasets
.map((dataset) => dataset.id as string)
.filter((id) => id?.startsWith("compare-"))
);
return datasets
.filter(
(dataset) =>
dataset.id && !(dataset.id as string).startsWith("compare-")
)
.map((dataset) => {
const id = dataset.id as string;
const compareId = `compare-${id}`;
return {
id,
secondaryIds: compareIds.has(compareId) ? [compareId] : [],
name: dataset.name as string,
};
});
}
private _processTotal(consumption: EnergyConsumptionData) {
return consumption.total.used_total > 0
? consumption.total.used_total
+1
View File
@@ -196,6 +196,7 @@ export interface EnergyDistributionCardConfig extends EnergyCardConfig {
}
export interface EnergyUsageGraphCardConfig extends EnergyCardConfig {
type: "energy-usage-graph";
show_legend?: boolean;
}
export interface EnergySolarGraphCardConfig extends EnergyCardConfig {
@@ -77,7 +77,7 @@ export class HuiEnergyGraphCardEditor
...(type !== "energy-compare"
? [{ name: "title", selector: { text: {} } }]
: []),
...(type === "power-sources-graph"
...(type === "power-sources-graph" || type === "energy-usage-graph"
? [
{
name: "show_legend",
+24 -3
View File
@@ -283,21 +283,42 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
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),
});
});
});
@@ -1,5 +1,786 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`HistoryStream characterization > adds a new entity, keeps an absent one, and sorts out-of-order states > multi-incremental 1`] = `
{
"sensor.power_a": [
{
"a": {
"device_class": "power",
"state_class": "measurement",
"unit_of_measurement": "W",
},
"lu": 1704146700,
"s": "138.89",
},
{
"a": {},
"lu": 1704146760,
"s": "146.73",
},
{
"a": {},
"lu": 1704146820,
"s": "145.59",
},
{
"a": {},
"lu": 1704146880,
"s": "142.40",
},
{
"a": {},
"lu": 1704146940,
"s": "134.33",
},
{
"a": {},
"lu": 1704147000,
"s": "135.42",
},
{
"a": {},
"lc": 1704147000,
"lu": 1704147060,
"s": "135.42",
},
{
"a": {},
"lu": 1704147120,
"s": "136.41",
},
{
"a": {},
"lc": 1704147120,
"lu": 1704147180,
"s": "136.41",
},
{
"a": {},
"lu": 1704147240,
"s": "144.38",
},
{
"a": {},
"lu": 1704147300,
"s": "138.05",
},
{
"a": {},
"lu": 1704147360,
"s": "134.28",
},
{
"a": {},
"lu": 1704147420,
"s": "132.05",
},
{
"a": {},
"lu": 1704147480,
"s": "137.12",
},
{
"a": {},
"lu": 1704147540,
"s": "145.55",
},
{
"a": {},
"lu": 1704147600,
"s": "138.11",
},
{
"a": {},
"lc": 1704147600,
"lu": 1704147660,
"s": "138.11",
},
{
"a": {},
"lu": 1704147720,
"s": "135.95",
},
{
"a": {},
"lu": 1704147780,
"s": "130.24",
},
{
"a": {},
"lu": 1704147840,
"s": "120.94",
},
{
"a": {},
"lc": 1704147840,
"lu": 1704147900,
"s": "120.94",
},
{
"a": {},
"lu": 1704147960,
"s": "125.98",
},
{
"a": {},
"lu": 1704148020,
"s": "120.26",
},
{
"a": {},
"lu": 1704148080,
"s": "115.37",
},
{
"a": {},
"lc": 1704148080,
"lu": 1704148140,
"s": "115.37",
},
{
"a": {},
"lu": 1704148200,
"s": "121.80",
},
{
"a": {
"device_class": "power",
"state_class": "measurement",
"unit_of_measurement": "W",
},
"lu": 1704148200,
"s": "111.91",
},
{
"a": {},
"lu": 1704148230,
"s": "106.20",
},
{
"a": {},
"lu": 1704148260,
"s": "118.13",
},
{
"a": {},
"lu": 1704148260,
"s": "112.72",
},
{
"a": {},
"lu": 1704148290,
"s": "106.90",
},
{
"a": {},
"lu": 1704148320,
"s": "123.85",
},
{
"a": {},
"lu": 1704148320,
"s": "113.68",
},
{
"a": {},
"lu": 1704148350,
"s": "104.79",
},
{
"a": {},
"lu": 1704148380,
"s": "117.09",
},
{
"a": {},
"lu": 1704148380,
"s": "114.50",
},
{
"a": {},
"lu": 1704148410,
"s": "109.11",
},
{
"a": {},
"lu": 1704148440,
"s": "108.15",
},
{
"a": {},
"lu": 1704148440,
"s": "102.25",
},
{
"a": {},
"lu": 1704148470,
"s": "102.42",
},
{
"a": {},
"lu": 1704148500,
"s": "105.34",
},
{
"a": {},
"lu": 1704148560,
"s": "102.07",
},
{
"a": {},
"lu": 1704148620,
"s": "108.77",
},
{
"a": {},
"lu": 1704148680,
"s": "108.01",
},
{
"a": {},
"lu": 1704148740,
"s": "98.07",
},
{
"a": {},
"lc": 1704148740,
"lu": 1704148800,
"s": "98.07",
},
{
"a": {},
"lu": 1704148860,
"s": "93.40",
},
{
"a": {},
"lu": 1704148920,
"s": "87.89",
},
{
"a": {},
"lu": 1704148980,
"s": "87.22",
},
{
"a": {},
"lu": 1704149040,
"s": "77.55",
},
{
"a": {},
"lu": 1704149100,
"s": "82.38",
},
{
"a": {},
"lu": 1704149160,
"s": "84.01",
},
{
"a": {},
"lu": 1704149220,
"s": "83.39",
},
{
"a": {},
"lu": 1704149280,
"s": "74.55",
},
{
"a": {},
"lu": 1704149340,
"s": "77.64",
},
{
"a": {},
"lu": 1704149400,
"s": "75.78",
},
{
"a": {},
"lu": 1704149460,
"s": "77.98",
},
{
"a": {},
"lc": 1704149460,
"lu": 1704149520,
"s": "77.98",
},
{
"a": {},
"lu": 1704149580,
"s": "72.36",
},
{
"a": {},
"lc": 1704149580,
"lu": 1704149640,
"s": "72.36",
},
{
"a": {},
"lu": 1704149700,
"s": "81.92",
},
{
"a": {},
"lu": 1704149760,
"s": "80.90",
},
{
"a": {},
"lu": 1704149820,
"s": "78.80",
},
{
"a": {},
"lu": 1704149880,
"s": "82.16",
},
{
"a": {},
"lu": 1704149940,
"s": "73.00",
},
{
"a": {},
"lu": 1704150000,
"s": "71.57",
},
{
"a": {},
"lu": 1704150060,
"s": "76.99",
},
{
"a": {},
"lu": 1704150120,
"s": "74.07",
},
{
"a": {},
"lu": 1704150180,
"s": "81.70",
},
{
"a": {},
"lu": 1704150240,
"s": "86.24",
},
],
"sensor.power_b": [
{
"a": {
"device_class": "power",
"state_class": "measurement",
"unit_of_measurement": "W",
},
"lu": 1704146700,
"s": "156.69",
},
{
"a": {},
"lu": 1704146760,
"s": "164.63",
},
{
"a": {},
"lu": 1704146820,
"s": "169.53",
},
{
"a": {},
"lc": 1704146820,
"lu": 1704146880,
"s": "169.53",
},
{
"a": {},
"lu": 1704146940,
"s": "169.48",
},
{
"a": {},
"lu": 1704147000,
"s": "163.97",
},
{
"a": {},
"lu": 1704147060,
"s": "167.29",
},
{
"a": {},
"lc": 1704147060,
"lu": 1704147120,
"s": "167.29",
},
{
"a": {},
"lu": 1704147180,
"s": "157.38",
},
{
"a": {},
"lu": 1704147240,
"s": "156.66",
},
{
"a": {},
"lu": 1704147300,
"s": "162.98",
},
{
"a": {},
"lu": 1704147360,
"s": "153.89",
},
{
"a": {},
"lu": 1704147420,
"s": "146.45",
},
{
"a": {},
"lu": 1704147480,
"s": "138.87",
},
{
"a": {},
"lu": 1704147540,
"s": "129.95",
},
{
"a": {},
"lu": 1704147600,
"s": "120.70",
},
{
"a": {},
"lu": 1704147660,
"s": "115.92",
},
{
"a": {},
"lu": 1704147720,
"s": "114.26",
},
{
"a": {},
"lu": 1704147780,
"s": "122.21",
},
{
"a": {},
"lu": 1704147840,
"s": "117.68",
},
{
"a": {},
"lu": 1704147900,
"s": "118.17",
},
{
"a": {},
"lu": 1704147960,
"s": "110.60",
},
{
"a": {},
"lu": 1704148020,
"s": "102.80",
},
{
"a": {},
"lu": 1704148080,
"s": "108.55",
},
{
"a": {},
"lu": 1704148140,
"s": "106.53",
},
{
"a": {},
"lc": 1704148140,
"lu": 1704148200,
"s": "106.53",
},
{
"a": {},
"lu": 1704148260,
"s": "103.79",
},
{
"a": {},
"lu": 1704148320,
"s": "96.61",
},
{
"a": {},
"lu": 1704148380,
"s": "96.37",
},
{
"a": {},
"lu": 1704148440,
"s": "93.71",
},
{
"a": {},
"lu": 1704148500,
"s": "89.91",
},
{
"a": {},
"lu": 1704148560,
"s": "91.07",
},
{
"a": {},
"lu": 1704148620,
"s": "89.65",
},
{
"a": {},
"lu": 1704148680,
"s": "87.00",
},
{
"a": {},
"lc": 1704148680,
"lu": 1704148740,
"s": "87.00",
},
{
"a": {},
"lc": 1704148680,
"lu": 1704148800,
"s": "87.00",
},
{
"a": {},
"lu": 1704148860,
"s": "86.74",
},
{
"a": {},
"lu": 1704148920,
"s": "86.65",
},
{
"a": {},
"lu": 1704148980,
"s": "76.83",
},
{
"a": {},
"lu": 1704149040,
"s": "78.89",
},
{
"a": {},
"lu": 1704149100,
"s": "81.20",
},
{
"a": {},
"lu": 1704149160,
"s": "91.15",
},
{
"a": {},
"lu": 1704149220,
"s": "82.79",
},
{
"a": {},
"lu": 1704149280,
"s": "83.28",
},
{
"a": {},
"lu": 1704149340,
"s": "86.50",
},
{
"a": {},
"lu": 1704149400,
"s": "86.21",
},
{
"a": {},
"lu": 1704149460,
"s": "85.99",
},
{
"a": {},
"lu": 1704149520,
"s": "82.53",
},
{
"a": {},
"lu": 1704149580,
"s": "89.38",
},
{
"a": {},
"lu": 1704149640,
"s": "97.42",
},
{
"a": {},
"lu": 1704149700,
"s": "93.07",
},
{
"a": {},
"lc": 1704149700,
"lu": 1704149760,
"s": "93.07",
},
{
"a": {},
"lc": 1704149700,
"lu": 1704149820,
"s": "93.07",
},
{
"a": {},
"lu": 1704149880,
"s": "89.71",
},
{
"a": {},
"lu": 1704149940,
"s": "81.27",
},
{
"a": {},
"lu": 1704150000,
"s": "73.73",
},
{
"a": {},
"lu": 1704150060,
"s": "79.25",
},
{
"a": {},
"lu": 1704150120,
"s": "78.33",
},
{
"a": {},
"lu": 1704150180,
"s": "68.70",
},
{
"a": {},
"lu": 1704150240,
"s": "69.64",
},
],
"sensor.power_c": [
{
"a": {
"device_class": "power",
"state_class": "measurement",
"unit_of_measurement": "W",
},
"lu": 1704153000,
"s": "129.08",
},
{
"a": {},
"lu": 1704153030,
"s": "121.42",
},
{
"a": {},
"lu": 1704153060,
"s": "128.97",
},
{
"a": {},
"lu": 1704153090,
"s": "125.56",
},
{
"a": {},
"lu": 1704153120,
"s": "128.99",
},
{
"a": {},
"lu": 1704153150,
"s": "137.29",
},
{
"a": {},
"lu": 1704153180,
"s": "142.91",
},
{
"a": {},
"lu": 1704153210,
"s": "145.58",
},
{
"a": {},
"lc": 1704153210,
"lu": 1704153240,
"s": "145.58",
},
{
"a": {},
"lu": 1704153270,
"s": "154.08",
},
{
"a": {},
"lu": 1704153300,
"s": "148.39",
},
{
"a": {},
"lu": 1704153330,
"s": "146.42",
},
{
"a": {},
"lc": 1704153330,
"lu": 1704153360,
"s": "146.42",
},
{
"a": {},
"lu": 1704153390,
"s": "144.87",
},
{
"a": {},
"lu": 1704153420,
"s": "152.62",
},
],
}
`;
exports[`HistoryStream characterization > adds a new entity, keeps an absent one, and sorts out-of-order states > multi-initial 1`] = `
{
"keys": [
"sensor.power_a",
"sensor.power_b",
],
"numberCount": 135,
"numberSum": "230060040600",
"type": "object",
}
`;
exports[`HistoryStream characterization > handles multiple entities 1`] = `
{
"binary_sensor.motion_hall": [
@@ -3054,6 +3835,98 @@ exports[`computeHistory characterization > matches snapshot with splitDeviceClas
}
`;
exports[`computeHistory characterization > resolves non-numeric domain units (zone, humidifier, water_heater) 1`] = `
{
"line": [
{
"data": [
{
"domain": "zone",
"entity_id": "zone.home",
"name": "Home zone",
"states": [
{
"attributes": {},
"last_changed": 1700000000000,
"state": "2",
},
{
"attributes": {},
"last_changed": 1700000060000,
"state": "3",
},
],
},
],
"device_class": "info_control.zone.graph_unit",
"identifier": "zone.home",
"unit": "ui.dialogs.more",
},
{
"data": [
{
"domain": "humidifier",
"entity_id": "humidifier.bedroom",
"name": "Bedroom humidifier",
"states": [
{
"attributes": {
"humidity": 45,
"mode": "auto",
},
"last_changed": 1700000000000,
"state": "on",
},
{
"attributes": {
"humidity": 50,
"mode": "auto",
},
"last_changed": 1700000060000,
"state": "on",
},
],
},
],
"device_class": undefined,
"identifier": "humidifier.bedroom",
"unit": "%",
},
{
"data": [
{
"domain": "water_heater",
"entity_id": "water_heater.tank",
"name": "Water heater",
"states": [
{
"attributes": {
"current_temperature": 48,
"temperature": 50,
},
"last_changed": 1700000000000,
"state": "eco",
},
{
"attributes": {
"current_temperature": 49,
"temperature": 55,
},
"last_changed": 1700000060000,
"state": "eco",
},
],
},
],
"device_class": undefined,
"identifier": "water_heater.tank",
"unit": "°C",
},
],
"timeline": [],
}
`;
exports[`convertStatisticsToHistory characterization > converts mean-based statistics 1`] = `
{
"line": [
+100
View File
@@ -115,6 +115,54 @@ describe("computeHistory characterization", () => {
)
).toMatchSnapshot();
});
it("resolves non-numeric domain units (zone, humidifier, water_heater)", () => {
// Pins the per-domain unit lookup for non-numeric line entities, which the
// mixed/climate fixtures above do not exercise.
const history = {
"zone.home": [
{ s: "2", a: { friendly_name: "Home zone" }, lu: 1_700_000_000 },
{ s: "3", a: {}, lu: 1_700_000_060 },
],
"humidifier.bedroom": [
{
s: "on",
a: {
friendly_name: "Bedroom humidifier",
humidity: 45,
mode: "auto",
},
lu: 1_700_000_000,
},
{ s: "on", a: { humidity: 50, mode: "auto" }, lu: 1_700_000_060 },
],
"water_heater.tank": [
{
s: "eco",
a: {
friendly_name: "Water heater",
temperature: 50,
current_temperature: 48,
},
lu: 1_700_000_000,
},
{
s: "eco",
a: { temperature: 55, current_temperature: 49 },
lu: 1_700_000_060,
},
],
};
expect(
computeHistory(
hass,
history,
[],
mockLocalize,
SENSOR_NUMERIC_DEVICE_CLASSES
)
).toMatchSnapshot();
});
});
describe("HistoryStream characterization", () => {
@@ -183,6 +231,58 @@ describe("HistoryStream characterization", () => {
});
expect(result).toMatchSnapshot();
});
it("adds a new entity, keeps an absent one, and sorts out-of-order states", () => {
const nowMs = FIXED_EPOCH_MS + 24 * 60 * 60 * 1000;
vi.useFakeTimers();
vi.setSystemTime(nowMs);
const hoursToShow = 2;
const stream = new HistoryStream(createMockHass(), hoursToShow);
const windowStartMs = nowMs - hoursToShow * 60 * 60 * 1000;
// Initial chunk with two entities.
const initial = stream.processMessage({
states: {
"sensor.power_a": generateNumericSensorStates(21, {
count: 60,
startMs: windowStartMs + 5 * 60 * 1000,
intervalMs: 60 * 1000,
jitter: 0,
}),
"sensor.power_b": generateNumericSensorStates(22, {
count: 60,
startMs: windowStartMs + 5 * 60 * 1000,
intervalMs: 60 * 1000,
jitter: 0,
}),
},
});
expect(digestResult(initial)).toMatchSnapshot("multi-initial");
// Incremental update that:
// - updates only sensor.power_a (sensor.power_b is absent and must be kept)
// - introduces a brand-new entity sensor.power_c (stream-only branch)
// - delivers sensor.power_a states out of order (older lu than the last
// combined state) to exercise the sort path
const incremental = stream.processMessage({
states: {
"sensor.power_a": generateNumericSensorStates(23, {
count: 10,
startMs: nowMs - 90 * 60 * 1000,
intervalMs: 30 * 1000,
jitter: 0,
}),
"sensor.power_c": generateNumericSensorStates(24, {
count: 15,
startMs: nowMs - 10 * 60 * 1000,
intervalMs: 30 * 1000,
jitter: 0,
}),
},
});
expect(incremental).toMatchSnapshot("multi-incremental");
});
});
describe("convertStatisticsToHistory characterization", () => {
@@ -272,6 +272,62 @@ describe("fillLineGaps", () => {
assert.equal(getX(secondItem), 2000);
assert.equal(secondItem.itemStyle.color, "red");
});
it("keeps the last item when a dataset has duplicate timestamps", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
[1000, 10],
[1000, 99],
[2000, 20],
],
},
];
const result = fillLineGaps(datasets);
// Two distinct buckets; the later [1000, 99] wins for bucket 1000.
assert.equal(result[0].data!.length, 2);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 99);
assert.equal(getX(result[0].data![1]), 2000);
assert.equal(getY(result[0].data![1]), 20);
});
it("produces a NaN bucket filled with zero across datasets", () => {
// A datapoint with no numeric x coerces to NaN. It adds a NaN bucket but is
// never stored in any dataset's map, so every dataset gets [NaN, 0] there.
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
[1000, 10],
[Number.NaN, 50],
],
},
{
type: "line",
data: [[1000, 100]],
},
];
const result = fillLineGaps(datasets);
// Buckets present: 1000 and NaN (NaN sorts to the end).
assert.equal(result[0].data!.length, 2);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 10);
assert.isTrue(Number.isNaN(getX(result[0].data![1])));
assert.equal(getY(result[0].data![1]), 0);
// Second dataset is aligned to the same buckets, NaN filled with zero.
assert.equal(result[1].data!.length, 2);
assert.equal(getX(result[1].data![0]), 1000);
assert.equal(getY(result[1].data![0]), 100);
assert.isTrue(Number.isNaN(getX(result[1].data![1])));
assert.equal(getY(result[1].data![1]), 0);
});
});
// Helper to get bar data item
@@ -0,0 +1,212 @@
/**
* Characterization tests pinning the exact output of the gas energy graph
* card's data transform. Do NOT update these snapshots to make an
* optimization pass see test/benchmarks/README.md.
*/
import { describe, expect, it } from "vitest";
import { generateEnergyGasGraphData } from "../../../../../src/panels/lovelace/cards/energy/energy-gas-graph-data";
import type { EnergyPreferences } from "../../../../../src/data/energy";
import type { HomeAssistant } from "../../../../../src/types";
import { createMockComputedStyle } from "../../../../fixtures/computed-style";
import { digestResult } from "../../../../fixtures/digest";
import {
createMockEntityState,
createMockHass,
} from "../../../../fixtures/hass";
import {
generateEnergyData,
generateEnergyPreferences,
} from "../../../../fixtures/energy";
// getEnergyColor resolves "--energy-gas-color" (and per-index variants) from
// the computed style, so supply a deterministic base color for the palette.
const computedStyles = createMockComputedStyle({
"--energy-gas-color": "#1b7ea0",
});
// The transform reads hass.themes.darkMode and hass.states (via
// getStatisticLabel). createMockHass covers states; layer themes on top.
const makeHass = (overrides: Partial<HomeAssistant> = {}): HomeAssistant =>
({
...createMockHass(),
themes: { darkMode: false },
...overrides,
}) as unknown as HomeAssistant;
// Energy preferences with only gas sources (the card filters to type "gas").
const gasOnlyPrefs = (sources: number): EnergyPreferences => ({
energy_sources: Array.from({ length: sources }, (_, i) => ({
type: "gas" as const,
stat_energy_from: `sensor.gas_consumption_${i}`,
stat_cost: null,
entity_energy_price: null,
number_energy_price: null,
})),
device_consumption: [],
device_consumption_water: [],
});
// Fixed "now" so the end fallback is deterministic.
const now = new Date("2024-01-31T23:59:59Z");
describe("generateEnergyGasGraphData", () => {
it("matches snapshot for a single gas source (no compare)", () => {
const energyData = generateEnergyData(1, {
days: 2,
period: "hour",
prefs: gasOnlyPrefs(1),
});
expect(
generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("matches snapshot for multiple gas sources (color brightening by idx)", () => {
const energyData = generateEnergyData(2, {
days: 2,
period: "hour",
prefs: gasOnlyPrefs(3),
});
expect(
generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("matches snapshot with compare data", () => {
const energyData = generateEnergyData(3, {
days: 2,
period: "hour",
compare: true,
prefs: gasOnlyPrefs(2),
});
expect(
generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("matches snapshot in dark mode", () => {
const energyData = generateEnergyData(4, {
days: 2,
period: "hour",
prefs: gasOnlyPrefs(2),
});
expect(
generateEnergyGasGraphData({
hass: makeHass({
themes: { darkMode: true } as HomeAssistant["themes"],
}),
energyData,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("uses the source name when provided, and the entity name from hass.states", () => {
const prefs: EnergyPreferences = {
energy_sources: [
{
type: "gas",
stat_energy_from: "sensor.gas_named",
name: "My gas meter",
stat_cost: null,
entity_energy_price: null,
number_energy_price: null,
},
{
type: "gas",
stat_energy_from: "sensor.gas_from_state",
stat_cost: null,
entity_energy_price: null,
number_energy_price: null,
},
],
device_consumption: [],
device_consumption_water: [],
};
const energyData = generateEnergyData(5, {
days: 2,
period: "hour",
prefs,
});
const hass = makeHass({
states: {
"sensor.gas_from_state": createMockEntityState(
"sensor.gas_from_state",
"42",
{ friendly_name: "Kitchen gas" }
),
} as HomeAssistant["states"],
});
expect(
generateEnergyGasGraphData({ hass, energyData, computedStyles, now })
).toMatchSnapshot();
});
it("handles no gas sources (empty placeholder dataset)", () => {
const energyData = generateEnergyData(6, {
days: 2,
period: "hour",
prefs: generateEnergyPreferences({ grid: true, solar: true }),
});
expect(
generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("falls back to now when energyData.end is missing", () => {
const energyData = generateEnergyData(7, {
days: 1,
period: "hour",
prefs: gasOnlyPrefs(1),
});
// Force the missing-end branch.
(energyData as { end?: Date }).end = undefined;
const result = generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
});
expect(result.end).toBe(now);
});
it("large 5-minute payload digest is stable (compare)", () => {
const energyData = generateEnergyData(42, {
days: 31,
period: "5minute",
compare: true,
prefs: gasOnlyPrefs(3),
});
expect(
digestResult(
generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
})
)
).toMatchSnapshot();
});
});
@@ -0,0 +1,212 @@
/**
* Characterization tests pinning the exact output of the solar energy graph
* card data transform. Do NOT update these snapshots to make an optimization
* pass see test/benchmarks/README.md.
*/
import { describe, expect, it } from "vitest";
import { generateEnergySolarGraphData } from "../../../../../src/panels/lovelace/cards/energy/energy-solar-graph-data";
import type {
EnergyPreferences,
EnergySolarForecasts,
SolarSourceTypeEnergyPreference,
} from "../../../../../src/data/energy";
import type { HomeAssistant } from "../../../../../src/types";
import { createMockComputedStyle } from "../../../../fixtures/computed-style";
import { digestResult } from "../../../../fixtures/digest";
import { createMockHass } from "../../../../fixtures/hass";
import { generateEnergyData } from "../../../../fixtures/energy";
import { FIXED_EPOCH_MS } from "../../../../fixtures/history-states";
const dayMs = 24 * 60 * 60 * 1000;
const computedStyles = createMockComputedStyle({
"--energy-solar-color": "#ff9800",
"--primary-text-color": "#212121",
});
const hass = {
...createMockHass(),
themes: { darkMode: false },
} as unknown as HomeAssistant;
const now = new Date(FIXED_EPOCH_MS + dayMs);
/** Prefs with one or two solar sources, optionally with forecast entries. */
const solarPrefs = (opts: {
sources?: number;
forecast?: boolean;
}): EnergyPreferences => {
const { sources = 1, forecast = false } = opts;
const energySources: SolarSourceTypeEnergyPreference[] = [];
for (let i = 0; i < sources; i++) {
energySources.push({
type: "solar",
stat_energy_from:
i === 0 ? "sensor.solar_production" : `sensor.solar_production_${i}`,
config_entry_solar_forecast: forecast ? [`entry_${i}`] : null,
...(i > 0 ? { name: `Roof ${i}` } : {}),
} as SolarSourceTypeEnergyPreference);
}
return {
energy_sources: energySources,
device_consumption: [],
device_consumption_water: [],
};
};
/** Deterministic forecast payload aligned to the data window. */
const buildForecasts = (
count: number,
stepMs: number,
entries: string[]
): EnergySolarForecasts => {
const result: EnergySolarForecasts = {};
entries.forEach((entry, e) => {
const wh: Record<string, number> = {};
for (let i = 0; i < count; i++) {
const t = new Date(FIXED_EPOCH_MS + i * stepMs);
wh[t.toISOString()] = ((i * 137 + e * 311 + 17) % 5000) + 1;
}
result[entry] = { wh_hours: wh };
});
return result;
};
describe("generateEnergySolarGraphData", () => {
it("returns a placeholder dataset and zero total for no solar sources", () => {
const energyData = generateEnergyData(1, {
days: 1,
period: "hour",
prefs: {
energy_sources: [],
device_consumption: [],
device_consumption_water: [],
},
});
const result = generateEnergySolarGraphData({
hass,
energyData,
forecasts: undefined,
computedStyles,
now,
});
expect(result.total).toBe(0);
expect(result).toMatchSnapshot();
});
it("matches snapshot for a single solar source, hourly, no compare", () => {
const energyData = generateEnergyData(2, {
days: 2,
period: "hour",
prefs: solarPrefs({ sources: 1 }),
});
expect(
generateEnergySolarGraphData({
hass,
energyData,
forecasts: undefined,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("matches snapshot for two solar sources with compare", () => {
const energyData = generateEnergyData(3, {
days: 2,
period: "hour",
compare: true,
prefs: solarPrefs({ sources: 2 }),
});
const result = generateEnergySolarGraphData({
hass,
energyData,
forecasts: undefined,
computedStyles,
now,
});
// compare statistics produce `compare-` prefixed datasets
expect(
result.chartData.some((d) => String(d.id).startsWith("compare-"))
).toBe(true);
expect(result).toMatchSnapshot();
});
it("matches snapshot for the daily period", () => {
const energyData = generateEnergyData(4, {
days: 40,
period: "day",
prefs: solarPrefs({ sources: 1 }),
});
expect(
generateEnergySolarGraphData({
hass,
energyData,
forecasts: undefined,
computedStyles,
now,
})
).toMatchSnapshot();
});
it("matches snapshot with hourly forecast data (sub-daily centering)", () => {
const energyData = generateEnergyData(5, {
days: 2,
period: "hour",
prefs: solarPrefs({ sources: 1, forecast: true }),
});
const forecasts = buildForecasts(2 * 24, 60 * 60 * 1000, ["entry_0"]);
const result = generateEnergySolarGraphData({
hass,
energyData,
forecasts,
computedStyles,
now,
});
expect(
result.chartData.some((d) => String(d.id).startsWith("forecast-"))
).toBe(true);
expect(result).toMatchSnapshot();
});
it("matches snapshot with daily-period forecast data (no centering)", () => {
const energyData = generateEnergyData(6, {
days: 40,
period: "day",
prefs: solarPrefs({ sources: 2, forecast: true }),
});
const forecasts = buildForecasts(40 * 24, 60 * 60 * 1000, [
"entry_0",
"entry_1",
]);
expect(
generateEnergySolarGraphData({
hass,
energyData,
forecasts,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 40 * dayMs),
})
).toMatchSnapshot();
});
it("large 5-minute payload digest is stable", () => {
const energyData = generateEnergyData(42, {
days: 31,
period: "5minute",
compare: true,
prefs: solarPrefs({ sources: 2 }),
});
expect(
digestResult(
generateEnergySolarGraphData({
hass,
energyData,
forecasts: undefined,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 31 * dayMs),
})
)
).toMatchSnapshot();
});
});
+10 -10
View File
@@ -7396,9 +7396,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.4.1":
version: 10.4.1
resolution: "eslint@npm:10.4.1"
"eslint@npm:10.5.0":
version: 10.5.0
resolution: "eslint@npm:10.5.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
@@ -7437,7 +7437,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/5722bd0ec1a87f49ee4511c7549dcfa69df0076927b0290b0a3b145425fd357906ffe6dc1307214a0bd344131bf53795fa2cdebfd36ee9146c01d98147044679
checksum: 10/28882f9b00803fca938015894d6e1ac2c80e00c1349385764f54ac4c0707c33fd0e32b678845c54ea0bc8ae04d59d7aa93d68dbd835e5e4833c65bf9b15ea91f
languageName: node
linkType: hard
@@ -8526,7 +8526,7 @@ __metadata:
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.1.0"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.4.1"
eslint: "npm:10.5.0"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.16.2"
@@ -9695,12 +9695,12 @@ __metadata:
linkType: hard
"launch-editor@npm:^2.13.2":
version: 2.13.2
resolution: "launch-editor@npm:2.13.2"
version: 2.14.1
resolution: "launch-editor@npm:2.14.1"
dependencies:
picocolors: "npm:^1.1.1"
shell-quote: "npm:^1.8.3"
checksum: 10/2b718ae4d3494526c9493a8c8f32e3824a79885e3b3be2e7e0db5ff74811b12af41760c4b904692cb43ddbd815ce65be245910e7ae84c3cc8ecbad4923657115
shell-quote: "npm:^1.8.4"
checksum: 10/335d12ca437280e77070657531c251b6c91c62bc653f70ab66ddd2a6e50131b1b043480411c5b93d54955a0a6eb8ec01e9a5b5cfe2d887341d878d19394a126b
languageName: node
linkType: hard
@@ -12266,7 +12266,7 @@ __metadata:
languageName: node
linkType: hard
"shell-quote@npm:^1.8.3":
"shell-quote@npm:^1.8.4":
version: 1.8.4
resolution: "shell-quote@npm:1.8.4"
checksum: 10/a3e3796385f2cd5cf0b78207a4439f0c7395c0833fc75b2473084b5d298c109c5c0fa687fcd1c04e4b4484866e5bb8eaae7efae443b80fff71ea7e29baf11f0c