mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-15 21:02:10 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22786df070 | |||
| e5c849359b | |||
| 4bfa4f2816 | |||
| afd86975d6 | |||
| 7b1eff9eef | |||
| 4f0c228756 | |||
| c86101ac6e | |||
| 29fa351b16 | |||
| 7c67633146 | |||
| 180e23ad9b | |||
| 9e7ddb3e5e | |||
| 4a0e46dc2c |
@@ -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);
|
||||
|
||||
+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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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