Merge pull request #9659 from home-assistant/dev

This commit is contained in:
Bram Kragten 2021-07-30 22:29:51 +02:00 committed by GitHub
commit 378e6d28bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 4219 additions and 2093 deletions

View File

@ -43,24 +43,24 @@
"@fullcalendar/interaction": "5.1.0", "@fullcalendar/interaction": "5.1.0",
"@fullcalendar/list": "5.1.0", "@fullcalendar/list": "5.1.0",
"@lit-labs/virtualizer": "patch:@lit-labs/virtualizer@0.6.0#./.yarn/patches/@lit-labs/virtualizer/0.7.0.patch", "@lit-labs/virtualizer": "patch:@lit-labs/virtualizer@0.6.0#./.yarn/patches/@lit-labs/virtualizer/0.7.0.patch",
"@material/chips": "=12.0.0-canary.1a8d06483.0", "@material/chips": "12.0.0-canary.22d29cbb4.0",
"@material/data-table": "=12.0.0-canary.1a8d06483.0", "@material/data-table": "12.0.0-canary.22d29cbb4.0",
"@material/mwc-button": "0.22.0-canary.cc04657a.0", "@material/mwc-button": "0.22.1",
"@material/mwc-checkbox": "0.22.0-canary.cc04657a.0", "@material/mwc-checkbox": "0.22.1",
"@material/mwc-circular-progress": "0.22.0-canary.cc04657a.0", "@material/mwc-circular-progress": "0.22.1",
"@material/mwc-dialog": "0.22.0-canary.cc04657a.0", "@material/mwc-dialog": "0.22.1",
"@material/mwc-fab": "0.22.0-canary.cc04657a.0", "@material/mwc-fab": "0.22.1",
"@material/mwc-formfield": "0.22.0-canary.cc04657a.0", "@material/mwc-formfield": "0.22.1",
"@material/mwc-icon-button": "0.22.0-canary.cc04657a.0", "@material/mwc-icon-button": "0.22.1",
"@material/mwc-linear-progress": "0.22.0-canary.cc04657a.0", "@material/mwc-linear-progress": "0.22.1",
"@material/mwc-list": "0.22.0-canary.cc04657a.0", "@material/mwc-list": "0.22.1",
"@material/mwc-menu": "0.22.0-canary.cc04657a.0", "@material/mwc-menu": "0.22.1",
"@material/mwc-radio": "0.22.0-canary.cc04657a.0", "@material/mwc-radio": "0.22.1",
"@material/mwc-ripple": "0.22.0-canary.cc04657a.0", "@material/mwc-ripple": "0.22.1",
"@material/mwc-switch": "0.22.0-canary.cc04657a.0", "@material/mwc-switch": "0.22.1",
"@material/mwc-tab": "0.22.0-canary.cc04657a.0", "@material/mwc-tab": "0.22.1",
"@material/mwc-tab-bar": "0.22.0-canary.cc04657a.0", "@material/mwc-tab-bar": "0.22.1",
"@material/top-app-bar": "=12.0.0-canary.1a8d06483.0", "@material/top-app-bar": "12.0.0-canary.22d29cbb4.0",
"@mdi/js": "5.9.55", "@mdi/js": "5.9.55",
"@mdi/svg": "5.9.55", "@mdi/svg": "5.9.55",
"@polymer/app-layout": "^3.1.0", "@polymer/app-layout": "^3.1.0",
@ -89,8 +89,8 @@
"@polymer/paper-tooltip": "^3.0.1", "@polymer/paper-tooltip": "^3.0.1",
"@polymer/polymer": "3.4.1", "@polymer/polymer": "3.4.1",
"@thomasloven/round-slider": "0.5.2", "@thomasloven/round-slider": "0.5.2",
"@vaadin/vaadin-combo-box": "^5.0.10", "@vaadin/vaadin-combo-box": "^20.0.1",
"@vaadin/vaadin-date-picker": "^4.0.7", "@vaadin/vaadin-date-picker": "^20.0.1",
"@vibrant/color": "^3.2.1-alpha.1", "@vibrant/color": "^3.2.1-alpha.1",
"@vibrant/core": "^3.2.1-alpha.1", "@vibrant/core": "^3.2.1-alpha.1",
"@vibrant/quantizer-mmcq": "^3.2.1-alpha.1", "@vibrant/quantizer-mmcq": "^3.2.1-alpha.1",
@ -107,7 +107,7 @@
"fuse.js": "^6.0.0", "fuse.js": "^6.0.0",
"google-timezones-json": "^1.0.2", "google-timezones-json": "^1.0.2",
"hls.js": "^1.0.7", "hls.js": "^1.0.7",
"home-assistant-js-websocket": "^5.10.0", "home-assistant-js-websocket": "^5.11.1",
"idb-keyval": "^5.0.5", "idb-keyval": "^5.0.5",
"intl-messageformat": "^9.6.16", "intl-messageformat": "^9.6.16",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",

View File

@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup( setup(
name="home-assistant-frontend", name="home-assistant-frontend",
version="20210726.0", version="20210730.0",
description="The Home Assistant frontend", description="The Home Assistant frontend",
url="https://github.com/home-assistant/frontend", url="https://github.com/home-assistant/frontend",
author="The Home Assistant Authors", author="The Home Assistant Authors",

View File

@ -63,6 +63,7 @@ export const FIXED_DEVICE_CLASS_ICONS = {
humidity: "hass:water-percent", humidity: "hass:water-percent",
illuminance: "hass:brightness-5", illuminance: "hass:brightness-5",
temperature: "hass:thermometer", temperature: "hass:thermometer",
monetary: "mdi:cash",
pressure: "hass:gauge", pressure: "hass:gauge",
power: "hass:flash", power: "hass:flash",
power_factor: "hass:angle-acute", power_factor: "hass:angle-acute",

View File

@ -21,6 +21,16 @@ export const computeStateDisplay = (
} }
if (stateObj.attributes.unit_of_measurement) { if (stateObj.attributes.unit_of_measurement) {
if (stateObj.attributes.device_class === "monetary") {
try {
return formatNumber(compareState, locale, {
style: "currency",
currency: stateObj.attributes.unit_of_measurement,
});
} catch (_err) {
// fallback to default
}
}
return `${formatNumber(compareState, locale)} ${ return `${formatNumber(compareState, locale)} ${
stateObj.attributes.unit_of_measurement stateObj.attributes.unit_of_measurement
}`; }`;

View File

@ -1,4 +1,22 @@
import { FrontendLocaleData, NumberFormat } from "../../data/translation"; import { FrontendLocaleData, NumberFormat } from "../../data/translation";
import { round } from "../number/round";
export const numberFormatToLocale = (
localeOptions: FrontendLocaleData
): string | string[] | undefined => {
switch (localeOptions.number_format) {
case NumberFormat.comma_decimal:
return ["en-US", "en"]; // Use United States with fallback to English formatting 1,234,567.89
case NumberFormat.decimal_comma:
return ["de", "es", "it"]; // Use German with fallback to Spanish then Italian formatting 1.234.567,89
case NumberFormat.space_comma:
return ["fr", "sv", "cs"]; // Use French with fallback to Swedish and Czech formatting 1 234 567,89
case NumberFormat.system:
return undefined;
default:
return localeOptions.language;
}
};
/** /**
* Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility. * Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility.
@ -9,27 +27,12 @@ import { FrontendLocaleData, NumberFormat } from "../../data/translation";
*/ */
export const formatNumber = ( export const formatNumber = (
num: string | number, num: string | number,
locale?: FrontendLocaleData, localeOptions?: FrontendLocaleData,
options?: Intl.NumberFormatOptions options?: Intl.NumberFormatOptions
): string => { ): string => {
let format: string | string[] | undefined; const locale = localeOptions
? numberFormatToLocale(localeOptions)
switch (locale?.number_format) { : undefined;
case NumberFormat.comma_decimal:
format = ["en-US", "en"]; // Use United States with fallback to English formatting 1,234,567.89
break;
case NumberFormat.decimal_comma:
format = ["de", "es", "it"]; // Use German with fallback to Spanish then Italian formatting 1.234.567,89
break;
case NumberFormat.space_comma:
format = ["fr", "sv", "cs"]; // Use French with fallback to Swedish and Czech formatting 1 234 567,89
break;
case NumberFormat.system:
format = undefined;
break;
default:
format = locale?.language;
}
// Polyfill for Number.isNaN, which is more reliable than the global isNaN() // Polyfill for Number.isNaN, which is more reliable than the global isNaN()
Number.isNaN = Number.isNaN =
@ -39,13 +42,13 @@ export const formatNumber = (
}; };
if ( if (
localeOptions?.number_format !== NumberFormat.none &&
!Number.isNaN(Number(num)) && !Number.isNaN(Number(num)) &&
Intl && Intl
locale?.number_format !== NumberFormat.none
) { ) {
try { try {
return new Intl.NumberFormat( return new Intl.NumberFormat(
format, locale,
getDefaultFormatOptions(num, options) getDefaultFormatOptions(num, options)
).format(Number(num)); ).format(Number(num));
} catch (error) { } catch (error) {
@ -58,7 +61,12 @@ export const formatNumber = (
).format(Number(num)); ).format(Number(num));
} }
} }
return num.toString(); if (typeof num === "string") {
return num;
}
return `${round(num, options?.maximumFractionDigits).toString()}${
options?.style === "currency" ? ` ${options.currency}` : ""
}`;
}; };
/** /**

View File

@ -5,7 +5,7 @@ import { customElement, property, query } from "lit/decorators";
import "../ha-circular-progress"; import "../ha-circular-progress";
@customElement("ha-progress-button") @customElement("ha-progress-button")
class HaProgressButton extends LitElement { export class HaProgressButton extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public progress = false; @property({ type: Boolean }) public progress = false;

View File

@ -55,7 +55,7 @@ export default class HaChartBase extends LitElement {
this._setupChart(); this._setupChart();
return; return;
} }
if (changedProps.has("type")) { if (changedProps.has("chartType")) {
this.chart.config.type = this.chartType; this.chart.config.type = this.chartType;
} }
if (changedProps.has("data")) { if (changedProps.has("data")) {
@ -136,12 +136,9 @@ export default class HaChartBase extends LitElement {
)} )}
</ul> </ul>
</div> </div>
${this._tooltip.footer ${this._tooltip.footer.length
? // footer has white-space: pre; ? html`<div class="footer">
// prettier-ignore ${this._tooltip.footer.map((item) => html`${item}<br />`)}
html`<div class="footer">${Array.isArray(this._tooltip.footer)
? this._tooltip.footer.join("\n")
: this._tooltip.footer}
</div>` </div>`
: ""} : ""}
</div>` </div>`
@ -155,7 +152,17 @@ export default class HaChartBase extends LitElement {
.querySelector("canvas")! .querySelector("canvas")!
.getContext("2d")!; .getContext("2d")!;
this.chart = new (await import("../../resources/chartjs")).Chart(ctx, { const ChartConstructor = (await import("../../resources/chartjs")).Chart;
const computedStyles = getComputedStyle(this);
ChartConstructor.defaults.borderColor =
computedStyles.getPropertyValue("--divider-color");
ChartConstructor.defaults.color = computedStyles.getPropertyValue(
"--secondary-text-color"
);
this.chart = new ChartConstructor(ctx, {
type: this.chartType, type: this.chartType,
data: this.data, data: this.data,
options: this._createOptions(), options: this._createOptions(),
@ -275,7 +282,7 @@ export default class HaChartBase extends LitElement {
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
height: 16px; height: 16px;
margin-right: 4px; margin-right: 6px;
width: 16px; width: 16px;
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box; box-sizing: border-box;
@ -283,9 +290,10 @@ export default class HaChartBase extends LitElement {
.chartTooltip .bullet { .chartTooltip .bullet {
align-self: baseline; align-self: baseline;
} }
:host([rtl]) .chartLegend .bullet,
:host([rtl]) .chartTooltip .bullet { :host([rtl]) .chartTooltip .bullet {
margin-right: inherit; margin-right: inherit;
margin-left: 4px; margin-left: 6px;
} }
.chartTooltip { .chartTooltip {
padding: 8px; padding: 8px;
@ -317,6 +325,7 @@ export default class HaChartBase extends LitElement {
white-space: pre-line; white-space: pre-line;
align-items: center; align-items: center;
line-height: 16px; line-height: 16px;
padding: 4px 0;
} }
.chartTooltip .title { .chartTooltip .title {
text-align: center; text-align: center;
@ -324,7 +333,6 @@ export default class HaChartBase extends LitElement {
} }
.chartTooltip .footer { .chartTooltip .footer {
font-weight: 500; font-weight: 500;
white-space: pre;
} }
.chartTooltip .beforeBody { .chartTooltip .beforeBody {
text-align: center; text-align: center;

View File

@ -2,6 +2,7 @@ import type { ChartData, ChartDataset, ChartOptions } from "chart.js";
import { html, LitElement, PropertyValues } from "lit"; import { html, LitElement, PropertyValues } from "lit";
import { property, state } from "lit/decorators"; import { property, state } from "lit/decorators";
import { getColorByIndex } from "../../common/color/colors"; import { getColorByIndex } from "../../common/color/colors";
import { numberFormatToLocale } from "../../common/string/format_number";
import { LineChartEntity, LineChartState } from "../../data/history"; import { LineChartEntity, LineChartState } from "../../data/history";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "./ha-chart-base"; import "./ha-chart-base";
@ -109,6 +110,8 @@ class StateHistoryChartLine extends LitElement {
hitRadius: 5, hitRadius: 5,
}, },
}, },
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
}; };
} }
if (changedProps.has("data")) { if (changedProps.has("data")) {

View File

@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import { getColorByIndex } from "../../common/color/colors"; import { getColorByIndex } from "../../common/color/colors";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { computeDomain } from "../../common/entity/compute_domain"; import { computeDomain } from "../../common/entity/compute_domain";
import { numberFormatToLocale } from "../../common/string/format_number";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
import { TimelineEntity } from "../../data/history"; import { TimelineEntity } from "../../data/history";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
@ -186,6 +187,8 @@ export class StateHistoryChartTimeline extends LitElement {
propagate: true, propagate: true,
}, },
}, },
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
}; };
} }
if (changedProps.has("data")) { if (changedProps.has("data")) {

View File

@ -16,6 +16,7 @@ import { customElement, property, state } from "lit/decorators";
import { getColorByIndex } from "../../common/color/colors"; import { getColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeStateName } from "../../common/entity/compute_state_name"; import { computeStateName } from "../../common/entity/compute_state_name";
import { numberFormatToLocale } from "../../common/string/format_number";
import { import {
Statistics, Statistics,
statisticsHaveType, statisticsHaveType,
@ -37,8 +38,8 @@ class StatisticsChart extends LitElement {
@property({ type: Array }) public statTypes: Array<StatisticType> = [ @property({ type: Array }) public statTypes: Array<StatisticType> = [
"sum", "sum",
"min", "min",
"max",
"mean", "mean",
"max",
]; ];
@property() public chartType: ChartType = "line"; @property() public chartType: ChartType = "line";
@ -57,7 +58,7 @@ class StatisticsChart extends LitElement {
if (!this.hasUpdated) { if (!this.hasUpdated) {
this._createOptions(); this._createOptions();
} }
if (changedProps.has("statisticsData")) { if (changedProps.has("statisticsData") || changedProps.has("statTypes")) {
this._generateData(); this._generateData();
} }
} }
@ -119,7 +120,7 @@ class StatisticsChart extends LitElement {
: {}, : {},
}, },
time: { time: {
tooltipFormat: "datetimeseconds", tooltipFormat: "datetime",
}, },
}, },
y: { y: {
@ -157,10 +158,15 @@ class StatisticsChart extends LitElement {
hitRadius: 5, hitRadius: 5,
}, },
}, },
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
}; };
} }
private _generateData() { private _generateData() {
if (!this.statisticsData) {
return;
}
let colorIndex = 0; let colorIndex = 0;
const statisticsData = Object.values(this.statisticsData); const statisticsData = Object.values(this.statisticsData);
const totalDataSets: ChartDataset<"line">[] = []; const totalDataSets: ChartDataset<"line">[] = [];
@ -228,21 +234,21 @@ class StatisticsChart extends LitElement {
prevValues = dataValues; prevValues = dataValues;
}; };
const color = getColorByIndex(colorIndex);
colorIndex++;
const addDataSet = ( const addDataSet = (
nameY: string, nameY: string,
borderColor: string,
backgroundColor: string,
step = false, step = false,
fill = false, fill?: boolean | number | string
color?: string
) => { ) => {
if (!color) {
color = getColorByIndex(colorIndex);
colorIndex++;
}
statDataSets.push({ statDataSets.push({
label: nameY, label: nameY,
fill: fill ? "origin" : false, fill: fill || false,
borderColor: color, borderColor,
backgroundColor: color + "7F", backgroundColor: backgroundColor,
stepped: step ? "before" : false, stepped: step ? "before" : false,
pointRadius: 0, pointRadius: 0,
data: [], data: [],
@ -251,26 +257,60 @@ class StatisticsChart extends LitElement {
const statTypes: this["statTypes"] = []; const statTypes: this["statTypes"] = [];
this.statTypes.forEach((type) => { const sortedTypes = [...this.statTypes].sort((a, _b) => {
if (a === "min") {
return -1;
}
if (a === "max") {
return +1;
}
return 0;
});
const drawBands =
this.statTypes.includes("mean") && statisticsHaveType(stats, "mean");
sortedTypes.forEach((type) => {
if (statisticsHaveType(stats, type)) { if (statisticsHaveType(stats, type)) {
statTypes.push(type); statTypes.push(type);
addDataSet( addDataSet(
`${name} (${this.hass.localize( `${name} (${this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}` `ui.components.statistics_charts.statistic_types.${type}`
)})`, )})`,
false drawBands && (type === "min" || type === "max")
? color + "7F"
: color,
color + "7F",
false,
drawBands
? type === "min"
? "+1"
: type === "max"
? "-1"
: false
: false
); );
} }
}); });
let prevDate: Date | null = null;
// Process chart data. // Process chart data.
stats.forEach((stat) => { stats.forEach((stat) => {
const date = new Date(stat.start);
if (prevDate === date) {
return;
}
prevDate = date;
const dataValues: Array<number | null> = []; const dataValues: Array<number | null> = [];
statTypes.forEach((type) => { statTypes.forEach((type) => {
const val = stat[type]; let val: number | null;
if (type === "sum") {
val = stat.state;
} else {
val = stat[type];
}
dataValues.push(val !== null ? Math.round(val * 100) / 100 : null); dataValues.push(val !== null ? Math.round(val * 100) / 100 : null);
}); });
const date = new Date(stat.start);
pushData(date, dataValues); pushData(date, dataValues);
}); });

View File

@ -0,0 +1,168 @@
export const createCurrencyListEl = () => {
const list = document.createElement("datalist");
list.id = "currencies";
for (const currency of [
"AED",
"AFN",
"ALL",
"AMD",
"ANG",
"AOA",
"ARS",
"AUD",
"AWG",
"AZN",
"BAM",
"BBD",
"BDT",
"BGN",
"BHD",
"BIF",
"BMD",
"BND",
"BOB",
"BRL",
"BSD",
"BTN",
"BWP",
"BYR",
"BZD",
"CAD",
"CDF",
"CHF",
"CLP",
"CNY",
"COP",
"CRC",
"CUP",
"CVE",
"CZK",
"DJF",
"DKK",
"DOP",
"DZD",
"EGP",
"ERN",
"ETB",
"EUR",
"FJD",
"FKP",
"GBP",
"GEL",
"GHS",
"GIP",
"GMD",
"GNF",
"GTQ",
"GYD",
"HKD",
"HNL",
"HRK",
"HTG",
"HUF",
"IDR",
"ILS",
"INR",
"IQD",
"IRR",
"ISK",
"JMD",
"JOD",
"JPY",
"KES",
"KGS",
"KHR",
"KMF",
"KPW",
"KRW",
"KWD",
"KYD",
"KZT",
"LAK",
"LBP",
"LKR",
"LRD",
"LSL",
"LTL",
"LYD",
"MAD",
"MDL",
"MGA",
"MKD",
"MMK",
"MNT",
"MOP",
"MRO",
"MUR",
"MVR",
"MWK",
"MXN",
"MYR",
"MZN",
"NAD",
"NGN",
"NIO",
"NOK",
"NPR",
"NZD",
"OMR",
"PAB",
"PEN",
"PGK",
"PHP",
"PKR",
"PLN",
"PYG",
"QAR",
"RON",
"RSD",
"RUB",
"RWF",
"SAR",
"SBD",
"SCR",
"SDG",
"SEK",
"SGD",
"SHP",
"SLL",
"SOS",
"SRD",
"SSP",
"STD",
"SYP",
"SZL",
"THB",
"TJS",
"TMT",
"TND",
"TOP",
"TRY",
"TTD",
"TWD",
"TZS",
"UAH",
"UGX",
"USD",
"UYU",
"UZS",
"VEF",
"VND",
"VUV",
"WST",
"XAF",
"XCD",
"XOF",
"XPF",
"YER",
"ZAR",
"ZMK",
"ZWL",
]) {
const option = document.createElement("option");
option.value = currency;
option.innerHTML = currency;
list.appendChild(option);
}
return list;
};

View File

@ -131,6 +131,9 @@ class HaEntitiesPickerLight extends LitElement {
private async _addEntity(event: PolymerChangedEvent<string>) { private async _addEntity(event: PolymerChangedEvent<string>) {
event.stopPropagation(); event.stopPropagation();
const toAdd = event.detail.value; const toAdd = event.detail.value;
if (!toAdd) {
return;
}
(event.currentTarget as any).value = ""; (event.currentTarget as any).value = "";
if (!toAdd) { if (!toAdd) {
return; return;

View File

@ -22,46 +22,12 @@ import { compare } from "../../common/string/compare";
import { getStatisticIds, StatisticsMetaData } from "../../data/history"; import { getStatisticIds, StatisticsMetaData } from "../../data/history";
import { PolymerChangedEvent } from "../../polymer-types"; import { PolymerChangedEvent } from "../../polymer-types";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-combo-box"; import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box"; import type { HaComboBox } from "../ha-combo-box";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "./state-badge"; import "./state-badge";
// vaadin-combo-box-item
const rowRenderer: ComboBoxLitRenderer<{
id: string;
name: string;
state?: HassEntity;
}> = (item) => html`<style>
paper-icon-item {
padding: 0;
margin: -8px;
}
#content {
display: flex;
align-items: center;
}
ha-svg-icon {
padding-left: 2px;
color: var(--secondary-text-color);
}
:host(:not([selected])) ha-svg-icon {
display: none;
}
:host([selected]) paper-icon-item {
margin-left: 0;
}
</style>
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
<paper-icon-item>
<state-badge slot="item-icon" .stateObj=${item.state}></state-badge>
<paper-item-body two-line="">
${item.name}
<span secondary>${item.id}</span>
</paper-item-body>
</paper-icon-item>`;
@customElement("ha-statistic-picker") @customElement("ha-statistic-picker")
export class HaStatisticPicker extends LitElement { export class HaStatisticPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -99,6 +65,53 @@ export class HaStatisticPicker extends LitElement {
private _init = false; private _init = false;
private _rowRenderer: ComboBoxLitRenderer<{
id: string;
name: string;
state?: HassEntity;
}> = (item) => html`<style>
paper-icon-item {
padding: 0;
margin: -8px;
}
#content {
display: flex;
align-items: center;
}
ha-svg-icon {
padding-left: 2px;
color: var(--secondary-text-color);
}
:host(:not([selected])) ha-svg-icon {
display: none;
}
:host([selected]) paper-icon-item {
margin-left: 0;
}
a {
color: var(--primary-color);
}
</style>
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
<paper-icon-item>
<state-badge slot="item-icon" .stateObj=${item.state}></state-badge>
<paper-item-body two-line="">
${item.name}
<span secondary
>${item.id === "" || item.id === "__missing"
? html`<a
target="_blank"
rel="noopener noreferrer"
href="${documentationUrl(this.hass, "/more-info/statistics/")}"
>${this.hass.localize(
"ui.components.statistic-picker.learn_more"
)}</a
>`
: item.id}</span
>
</paper-item-body>
</paper-icon-item>`;
private _getStatistics = memoizeOne( private _getStatistics = memoizeOne(
( (
statisticIds: StatisticsMetaData[], statisticIds: StatisticsMetaData[],
@ -110,7 +123,7 @@ export class HaStatisticPicker extends LitElement {
{ {
id: "", id: "",
name: this.hass.localize( name: this.hass.localize(
"ui.components.statistics-picker.no_statistics" "ui.components.statistic-picker.no_statistics"
), ),
}, },
]; ];
@ -142,10 +155,27 @@ export class HaStatisticPicker extends LitElement {
}); });
}); });
if (output.length === 1) { if (!output.length) {
return output; return [
{
id: "",
name: this.hass.localize("ui.components.statistic-picker.no_match"),
},
];
} }
return output.sort((a, b) => compare(a.name || "", b.name || ""));
if (output.length > 1) {
output.sort((a, b) => compare(a.name || "", b.name || ""));
}
output.push({
id: "__missing",
name: this.hass.localize(
"ui.components.statistic-picker.missing_entity"
),
});
return output;
} }
); );
@ -195,7 +225,7 @@ export class HaStatisticPicker extends LitElement {
? this.hass.localize("ui.components.statistic-picker.statistic") ? this.hass.localize("ui.components.statistic-picker.statistic")
: this.label} : this.label}
.value=${this._value} .value=${this._value}
.renderer=${rowRenderer} .renderer=${this._rowRenderer}
.disabled=${this.disabled} .disabled=${this.disabled}
item-value-path="id" item-value-path="id"
item-id-path="id" item-id-path="id"
@ -216,7 +246,10 @@ export class HaStatisticPicker extends LitElement {
private _statisticChanged(ev: PolymerChangedEvent<string>) { private _statisticChanged(ev: PolymerChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
const newValue = ev.detail.value; let newValue = ev.detail.value;
if (newValue === "__missing") {
newValue = "";
}
if (newValue !== this._value) { if (newValue !== this._value) {
this._setValue(newValue); this._setValue(newValue);

View File

@ -90,6 +90,9 @@ class HaStatisticsPicker extends LitElement {
private async _addStatistic(event: PolymerChangedEvent<string>) { private async _addStatistic(event: PolymerChangedEvent<string>) {
event.stopPropagation(); event.stopPropagation();
const toAdd = event.detail.value; const toAdd = event.detail.value;
if (!toAdd) {
return;
}
(event.currentTarget as any).value = ""; (event.currentTarget as any).value = "";
if (!toAdd) { if (!toAdd) {
return; return;

View File

@ -13,6 +13,11 @@ const getAngle = (value: number, min: number, max: number) => {
return (percentage * 180) / 100; return (percentage * 180) / 100;
}; };
export interface LevelDefinition {
level: number;
stroke: string;
}
@customElement("ha-gauge") @customElement("ha-gauge")
export class Gauge extends LitElement { export class Gauge extends LitElement {
@property({ type: Number }) public min = 0; @property({ type: Number }) public min = 0;
@ -21,8 +26,14 @@ export class Gauge extends LitElement {
@property({ type: Number }) public value = 0; @property({ type: Number }) public value = 0;
@property({ type: String }) public valueText?: string;
@property() public locale!: FrontendLocaleData; @property() public locale!: FrontendLocaleData;
@property({ type: Boolean }) public needle?: boolean;
@property() public levels?: LevelDefinition[];
@property() public label = ""; @property() public label = "";
@state() private _angle = 0; @state() private _angle = 0;
@ -51,22 +62,61 @@ export class Gauge extends LitElement {
protected render() { protected render() {
return svg` return svg`
<svg viewBox="0 0 100 50" class="gauge"> <svg viewBox="0 0 100 50" class="gauge">
<path ${
!this.needle || !this.levels
? svg`<path
class="dial" class="dial"
d="M 10 50 A 40 40 0 0 1 90 50" d="M 10 50 A 40 40 0 0 1 90 50"
></path> ></path>`
<path : ""
class="value" }
d="M 90 50.001 A 40 40 0 0 1 10 50"
style=${ifDefined( ${
!isSafari this.levels
? styleMap({ transform: `rotate(${this._angle}deg)` }) ? this.levels
: undefined .sort((a, b) => a.level - b.level)
)} .map((level) => {
transform=${ifDefined( const angle = getAngle(level.level, this.min, this.max);
isSafari ? `rotate(${this._angle} 50 50)` : undefined return svg`<path
)} stroke="${level.stroke}"
> class="level"
d="M
${50 - 40 * Math.cos((angle * Math.PI) / 180)}
${50 - 40 * Math.sin((angle * Math.PI) / 180)}
A 40 40 0 0 1 90 50
"
></path>`;
})
: ""
}
${
this.needle
? svg`<path
class="needle"
d="M 25 47.5 L 2.5 50 L 25 52.5 z"
style=${ifDefined(
!isSafari
? styleMap({ transform: `rotate(${this._angle}deg)` })
: undefined
)}
transform=${ifDefined(
isSafari ? `rotate(${this._angle} 50 50)` : undefined
)}
>
`
: svg`<path
class="value"
d="M 90 50.001 A 40 40 0 0 1 10 50"
style=${ifDefined(
!isSafari
? styleMap({ transform: `rotate(${this._angle}deg)` })
: undefined
)}
transform=${ifDefined(
isSafari ? `rotate(${this._angle} 50 50)` : undefined
)}
>`
}
${ ${
// Workaround for https://github.com/home-assistant/frontend/issues/6467 // Workaround for https://github.com/home-assistant/frontend/issues/6467
isSafari isSafari
@ -83,7 +133,9 @@ export class Gauge extends LitElement {
</svg> </svg>
<svg class="text"> <svg class="text">
<text class="value-text"> <text class="value-text">
${formatNumber(this.value, this.locale)} ${this.label} ${this.valueText || formatNumber(this.value, this.locale)} ${
this.label
}
</text> </text>
</svg>`; </svg>`;
} }
@ -117,6 +169,15 @@ export class Gauge extends LitElement {
transform-origin: 50% 100%; transform-origin: 50% 100%;
transition: all 1s ease 0s; transition: all 1s ease 0s;
} }
.needle {
fill: var(--primary-text-color);
transform-origin: 50% 100%;
transition: all 1s ease 0s;
}
.level {
fill: none;
stroke-width: 15;
}
.gauge { .gauge {
display: block; display: block;
} }

View File

@ -53,9 +53,10 @@ const SHOW_AFTER_SPACER = ["config", "developer-tools", "hassio"];
const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body; const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body;
const SORT_VALUE_URL_PATHS = { const SORT_VALUE_URL_PATHS = {
map: 1, energy: 1,
logbook: 2, map: 2,
history: 3, logbook: 3,
history: 4,
"developer-tools": 9, "developer-tools": 9,
hassio: 10, hassio: 10,
config: 11, config: 11,

View File

@ -10,6 +10,7 @@ export interface ConfigUpdateValues {
time_zone: string; time_zone: string;
external_url?: string | null; external_url?: string | null;
internal_url?: string | null; internal_url?: string | null;
currency?: string | null;
} }
export interface CheckConfigResult { export interface CheckConfigResult {

8
src/data/currency.ts Normal file
View File

@ -0,0 +1,8 @@
export const SYMBOL_TO_ISO = {
$: "USD",
"€": "EUR",
"¥": "JPY",
"£": "GBP",
"₽": "RUB",
"₹": "INR",
};

View File

@ -85,7 +85,6 @@ type EnergySource =
| GridSourceTypeEnergyPreference; | GridSourceTypeEnergyPreference;
export interface EnergyPreferences { export interface EnergyPreferences {
currency: string;
energy_sources: EnergySource[]; energy_sources: EnergySource[];
device_consumption: DeviceConsumptionEnergyPreference[]; device_consumption: DeviceConsumptionEnergyPreference[];
} }

View File

@ -302,12 +302,9 @@ export const fetchStatistics = (
export const calculateStatisticSumGrowth = ( export const calculateStatisticSumGrowth = (
values: StatisticValue[] values: StatisticValue[]
): number | null => { ): number | null => {
if (values.length === 0) { if (!values || values.length < 2) {
return null; return null;
} }
if (values.length === 1) {
return values[0].sum;
}
const endSum = values[values.length - 1].sum; const endSum = values[values.length - 1].sum;
if (endSum === null) { if (endSum === null) {
return null; return null;
@ -323,19 +320,22 @@ export const calculateStatisticsSumGrowth = (
data: Statistics, data: Statistics,
stats: string[] stats: string[]
): number | null => { ): number | null => {
let totalGrowth = 0; let totalGrowth: number | null = null;
for (const stat of stats) { for (const stat of stats) {
if (!(stat in data)) { if (!(stat in data)) {
return null; continue;
} }
const statGrowth = calculateStatisticSumGrowth(data[stat]); const statGrowth = calculateStatisticSumGrowth(data[stat]);
if (statGrowth === null) { if (statGrowth === null) {
return null; continue;
}
if (totalGrowth === null) {
totalGrowth = statGrowth;
} else {
totalGrowth += statGrowth;
} }
totalGrowth += statGrowth;
} }
return totalGrowth; return totalGrowth;
@ -345,3 +345,128 @@ export const statisticsHaveType = (
stats: StatisticValue[], stats: StatisticValue[],
type: StatisticType type: StatisticType
) => stats.some((stat) => stat[type] !== null); ) => stats.some((stat) => stat[type] !== null);
/**
* Get the earliest start from a list of statistics.
*/
const getMinStatisticStart = (stats: StatisticValue[][]): string | null => {
let earliestString: string | null = null;
let earliestTime: Date | null = null;
for (const stat of stats) {
if (stat.length === 0) {
continue;
}
const curTime = new Date(stat[0].start);
if (earliestString === null) {
earliestString = stat[0].start;
earliestTime = curTime;
continue;
}
if (curTime < earliestTime!) {
earliestString = stat[0].start;
earliestTime = curTime;
}
}
return earliestString;
};
// Merge multiple sum statistics into one
const mergeSumStatistics = (stats: StatisticValue[][]) => {
const result: { start: string; sum: number }[] = [];
const statsCopy: StatisticValue[][] = stats.map((stat) => [...stat]);
while (statsCopy.some((stat) => stat.length > 0)) {
const earliestStart = getMinStatisticStart(statsCopy)!;
let sum = 0;
for (const stat of statsCopy) {
if (stat.length === 0) {
continue;
}
if (stat[0].start !== earliestStart) {
continue;
}
const statVal = stat.shift()!;
if (!statVal.sum) {
continue;
}
sum += statVal.sum;
}
result.push({
start: earliestStart,
sum,
});
}
return result;
};
/**
* Get the growth of a statistic over the given period while applying a
* per-period percentage.
*/
export const calculateStatisticsSumGrowthWithPercentage = (
percentageStat: StatisticValue[],
sumStats: StatisticValue[][]
): number | null => {
let sum: number | null = null;
if (sumStats.length === 0) {
return null;
}
const sumStatsToProcess = mergeSumStatistics(sumStats);
const percentageStatToProcess = [...percentageStat];
let lastSum: number | null = null;
// pre-populate lastSum with last sum statistic _before_ the first percentage statistic
for (const stat of sumStatsToProcess) {
if (new Date(stat.start) >= new Date(percentageStat[0].start)) {
break;
}
lastSum = stat.sum;
}
while (percentageStatToProcess.length > 0) {
if (!sumStatsToProcess.length) {
return sum;
}
// If they are not equal, pop the value that is earlier in time
if (sumStatsToProcess[0].start !== percentageStatToProcess[0].start) {
if (
new Date(sumStatsToProcess[0].start) <
new Date(percentageStatToProcess[0].start)
) {
sumStatsToProcess.shift();
} else {
percentageStatToProcess.shift();
}
continue;
}
const sumStatValue = sumStatsToProcess.shift()!;
const percentageStatValue = percentageStatToProcess.shift()!;
if (lastSum !== null) {
const sumGrowth = sumStatValue.sum! - lastSum;
if (sum === null) {
sum = sumGrowth * (percentageStatValue.mean! / 100);
} else {
sum += sumGrowth * (percentageStatValue.mean! / 100);
}
}
lastSum = sumStatValue.sum;
}
return sum;
};

View File

@ -83,6 +83,12 @@ export interface ZWaveJSHealNetworkStatusMessage {
heal_node_status: { [key: number]: string }; heal_node_status: { [key: number]: string };
} }
export interface ZWaveJSRemovedNode {
node_id: number;
manufacturer: string;
label: string;
}
export enum NodeStatus { export enum NodeStatus {
Unknown, Unknown,
Asleep, Asleep,
@ -178,6 +184,32 @@ export const reinterviewNode = (
} }
); );
export const healNode = (
hass: HomeAssistant,
entry_id: string,
node_id: number
): Promise<boolean> =>
hass.callWS({
type: "zwave_js/heal_node",
entry_id: entry_id,
node_id: node_id,
});
export const removeFailedNode = (
hass: HomeAssistant,
entry_id: string,
node_id: number,
callbackFunction: (message: any) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage(
(message: any) => callbackFunction(message),
{
type: "zwave_js/remove_failed_node",
entry_id: entry_id,
node_id: node_id,
}
);
export const healNetwork = ( export const healNetwork = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string entry_id: string

View File

@ -22,4 +22,5 @@ export const demoConfig: HassConfig = {
state: STATE_RUNNING, state: STATE_RUNNING,
internal_url: "http://homeassistant.local:8123", internal_url: "http://homeassistant.local:8123",
external_url: null, external_url: null,
currency: "USD",
}; };

View File

@ -132,6 +132,7 @@ export class HaTimeCondition extends LitElement implements ConditionElement {
.value=${before?.startsWith("input_datetime.") ? before : ""} .value=${before?.startsWith("input_datetime.") ? before : ""}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
.hass=${this.hass} .hass=${this.hass}
allow-custom-entity
></ha-entity-picker>` ></ha-entity-picker>`
: html`<paper-input : html`<paper-input
.label=${this.hass.localize( .label=${this.hass.localize(

View File

@ -7,11 +7,13 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { UNIT_C } from "../../../common/const"; import { UNIT_C } from "../../../common/const";
import { createCurrencyListEl } from "../../../components/currency-datalist";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/map/ha-locations-editor"; import "../../../components/map/ha-locations-editor";
import type { MarkerLocation } from "../../../components/map/ha-locations-editor"; import type { MarkerLocation } from "../../../components/map/ha-locations-editor";
import { createTimezoneListEl } from "../../../components/timezone-datalist"; import { createTimezoneListEl } from "../../../components/timezone-datalist";
import { ConfigUpdateValues, saveCoreConfig } from "../../../data/core"; import { ConfigUpdateValues, saveCoreConfig } from "../../../data/core";
import { SYMBOL_TO_ISO } from "../../../data/currency";
import type { PolymerChangedEvent } from "../../../polymer-types"; import type { PolymerChangedEvent } from "../../../polymer-types";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
@ -23,6 +25,8 @@ class ConfigCoreForm extends LitElement {
@state() private _location?: [number, number]; @state() private _location?: [number, number];
@state() private _currency?: string;
@state() private _elevation?: string; @state() private _elevation?: string;
@state() private _unitSystem?: ConfigUpdateValues["unit_system"]; @state() private _unitSystem?: ConfigUpdateValues["unit_system"];
@ -143,6 +147,33 @@ class ConfigCoreForm extends LitElement {
</paper-radio-button> </paper-radio-button>
</paper-radio-group> </paper-radio-group>
</div> </div>
<div class="row">
<div class="flex">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.currency"
)}<br />
<a
href="https://en.wikipedia.org/wiki/ISO_4217#Active_codes"
target="_blank"
rel="noopener noreferrer"
>${this.hass.localize(
"ui.panel.config.core.section.core.core_config.find_currency_value"
)}</a
>
</div>
<paper-input
class="flex"
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.currency"
)}
name="currency"
list="currencies"
.disabled=${disabled}
.value=${this._currencyValue}
@value-changed=${this._handleChange}
></paper-input>
</div>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<mwc-button @click=${this._save} .disabled=${disabled}> <mwc-button @click=${this._save} .disabled=${disabled}>
@ -157,10 +188,16 @@ class ConfigCoreForm extends LitElement {
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
const input = this.shadowRoot!.querySelector(
const tzInput = this.shadowRoot!.querySelector(
"[name=timeZone]" "[name=timeZone]"
) as PaperInputElement; ) as PaperInputElement;
input.inputElement.appendChild(createTimezoneListEl()); tzInput.inputElement.appendChild(createTimezoneListEl());
const cInput = this.shadowRoot!.querySelector(
"[name=currency]"
) as PaperInputElement;
cInput.inputElement.appendChild(createCurrencyListEl());
} }
private _markerLocation = memoizeOne( private _markerLocation = memoizeOne(
@ -178,6 +215,12 @@ class ConfigCoreForm extends LitElement {
] ]
); );
private get _currencyValue() {
return this._currency !== undefined
? this._currency
: this.hass.config.currency;
}
private get _elevationValue() { private get _elevationValue() {
return this._elevation !== undefined return this._elevation !== undefined
? this._elevation ? this._elevation
@ -200,7 +243,15 @@ class ConfigCoreForm extends LitElement {
private _handleChange(ev: PolymerChangedEvent<string>) { private _handleChange(ev: PolymerChangedEvent<string>) {
const target = ev.currentTarget as PaperInputElement; const target = ev.currentTarget as PaperInputElement;
this[`_${target.name}`] = target.value; let value = target.value;
if (target.name === "currency" && value) {
if (value in SYMBOL_TO_ISO) {
value = SYMBOL_TO_ISO[value];
}
}
this[`_${target.name}`] = value;
} }
private _locationChanged(ev) { private _locationChanged(ev) {
@ -223,12 +274,13 @@ class ConfigCoreForm extends LitElement {
await saveCoreConfig(this.hass, { await saveCoreConfig(this.hass, {
latitude: location[0], latitude: location[0],
longitude: location[1], longitude: location[1],
currency: this._currencyValue,
elevation: Number(this._elevationValue), elevation: Number(this._elevationValue),
unit_system: this._unitSystemValue, unit_system: this._unitSystemValue,
time_zone: this._timeZoneValue, time_zone: this._timeZoneValue,
}); });
} catch (err) { } catch (err) {
alert("FAIL"); alert(`Error saving config: ${err.message}`);
} finally { } finally {
this._working = false; this._working = false;
} }
@ -258,6 +310,10 @@ class ConfigCoreForm extends LitElement {
.card-actions { .card-actions {
text-align: right; text-align: right;
} }
a {
color: var(--primary-color);
}
`; `;
} }
} }

View File

@ -16,6 +16,8 @@ import {
import { haStyle } from "../../../../../../resources/styles"; import { haStyle } from "../../../../../../resources/styles";
import { HomeAssistant } from "../../../../../../types"; import { HomeAssistant } from "../../../../../../types";
import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node"; import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node";
import { showZWaveJSHealNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-heal-node";
import { showZWaveJSRemoveFailedNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node";
@customElement("ha-device-actions-zwave_js") @customElement("ha-device-actions-zwave_js")
export class HaDeviceActionsZWaveJS extends LitElement { export class HaDeviceActionsZWaveJS extends LitElement {
@ -56,6 +58,14 @@ export class HaDeviceActionsZWaveJS extends LitElement {
"ui.panel.config.zwave_js.device_info.reinterview_device" "ui.panel.config.zwave_js.device_info.reinterview_device"
)} )}
</mwc-button> </mwc-button>
<mwc-button @click=${this._healNodeClicked}>
${this.hass.localize("ui.panel.config.zwave_js.device_info.heal_node")}
</mwc-button>
<mwc-button @click=${this._removeFailedNode}>
${this.hass.localize(
"ui.panel.config.zwave_js.device_info.remove_failed"
)}
</mwc-button>
`; `;
} }
@ -69,6 +79,27 @@ export class HaDeviceActionsZWaveJS extends LitElement {
}); });
} }
private async _healNodeClicked() {
if (!this._nodeId || !this._entryId) {
return;
}
showZWaveJSHealNodeDialog(this, {
entry_id: this._entryId,
node_id: this._nodeId,
device: this.device,
});
}
private async _removeFailedNode() {
if (!this._nodeId || !this._entryId) {
return;
}
showZWaveJSRemoveFailedNodeDialog(this, {
entry_id: this._entryId,
node_id: this._nodeId,
});
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@ -19,6 +19,7 @@ import {
} from "../../../../dialogs/generic/show-dialog-box"; } from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles"; import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import { showEnergySettingsDeviceDialog } from "../dialogs/show-dialogs-energy"; import { showEnergySettingsDeviceDialog } from "../dialogs/show-dialogs-energy";
import { energyCardStyles } from "./styles"; import { energyCardStyles } from "./styles";
@ -33,12 +34,29 @@ export class EnergyDeviceSettings extends LitElement {
return html` return html`
<ha-card> <ha-card>
<h1 class="card-header"> <h1 class="card-header">
<ha-svg-icon .path=${mdiDevices}></ha-svg-icon>Monitor individual <ha-svg-icon .path=${mdiDevices}></ha-svg-icon>
devices ${this.hass.localize(
"ui.panel.config.energy.device_consumption.title"
)}
</h1> </h1>
<div class="card-content"> <div class="card-content">
<p>Monitor individual devices.</p> <p>
${this.hass.localize(
"ui.panel.config.energy.device_consumption.sub"
)}
<a
target="_blank"
rel="noopener noreferrer"
href="${documentationUrl(
this.hass,
"/docs/energy/individual-devices/"
)}"
>${this.hass.localize(
"ui.panel.config.energy.device_consumption.learn_more"
)}</a
>
</p>
<h3>Devices</h3> <h3>Devices</h3>
${this.preferences.device_consumption.map((device) => { ${this.preferences.device_consumption.map((device) => {
const entityState = this.hass.states[device.stat_consumption]; const entityState = this.hass.states[device.stat_consumption];

View File

@ -24,6 +24,7 @@ import {
energySourcesByType, energySourcesByType,
FlowFromGridSourceEnergyPreference, FlowFromGridSourceEnergyPreference,
FlowToGridSourceEnergyPreference, FlowToGridSourceEnergyPreference,
GridSourceTypeEnergyPreference,
saveEnergyPreferences, saveEnergyPreferences,
} from "../../../../data/energy"; } from "../../../../data/energy";
import { showConfigFlowDialog } from "../../../../dialogs/config-flow/show-dialog-config-flow"; import { showConfigFlowDialog } from "../../../../dialogs/config-flow/show-dialog-config-flow";
@ -33,6 +34,7 @@ import {
} from "../../../../dialogs/generic/show-dialog-box"; } from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles"; import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import { import {
showEnergySettingsGridFlowFromDialog, showEnergySettingsGridFlowFromDialog,
showEnergySettingsGridFlowToDialog, showEnergySettingsGridFlowToDialog,
@ -62,12 +64,25 @@ export class EnergyGridSettings extends LitElement {
return html` return html`
<ha-card> <ha-card>
<h1 class="card-header"> <h1 class="card-header">
<ha-svg-icon .path=${mdiTransmissionTower}></ha-svg-icon <ha-svg-icon .path=${mdiTransmissionTower}></ha-svg-icon>
>${this.hass.localize("ui.panel.config.energy.grid.title")} ${this.hass.localize("ui.panel.config.energy.grid.title")}
</h1> </h1>
<div class="card-content"> <div class="card-content">
<p>${this.hass.localize("ui.panel.config.energy.grid.sub")}</p> <p>
${this.hass.localize("ui.panel.config.energy.grid.sub")}
<a
target="_blank"
rel="noopener noreferrer"
href="${documentationUrl(
this.hass,
"/docs/energy/electricity-grid/"
)}"
>${this.hass.localize(
"ui.panel.config.energy.grid.learn_more"
)}</a
>
</p>
<h3>Grid consumption</h3> <h3>Grid consumption</h3>
${gridSource.flow_from.map((flow) => { ${gridSource.flow_from.map((flow) => {
const entityState = this.hass.states[flow.stat_energy_from]; const entityState = this.hass.states[flow.stat_energy_from];
@ -200,19 +215,33 @@ export class EnergyGridSettings extends LitElement {
private _addFromSource() { private _addFromSource() {
showEnergySettingsGridFlowFromDialog(this, { showEnergySettingsGridFlowFromDialog(this, {
currency: this.preferences.currency, saveCallback: async (flow) => {
saveCallback: async (source) => { let preferences: EnergyPreferences;
const flowFrom = energySourcesByType(this.preferences).grid![0] const gridSource = this.preferences.energy_sources.find(
.flow_from; (src) => src.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
const preferences: EnergyPreferences = { if (!gridSource) {
...this.preferences, preferences = {
energy_sources: this.preferences.energy_sources.map((src) => ...this.preferences,
src.type === "grid" energy_sources: [
? { ...src, flow_from: [...flowFrom, source] } ...this.preferences.energy_sources,
: src {
), ...emptyGridSourceEnergyPreference(),
}; flow_from: [flow],
},
],
};
} else {
preferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? { ...src, flow_from: [...gridSource.flow_from, flow] }
: src
),
};
}
await this._savePreferences(preferences); await this._savePreferences(preferences);
}, },
}); });
@ -220,16 +249,33 @@ export class EnergyGridSettings extends LitElement {
private _addToSource() { private _addToSource() {
showEnergySettingsGridFlowToDialog(this, { showEnergySettingsGridFlowToDialog(this, {
currency: this.preferences.currency, saveCallback: async (flow) => {
saveCallback: async (source) => { let preferences: EnergyPreferences;
const flowTo = energySourcesByType(this.preferences).grid![0].flow_to; const gridSource = this.preferences.energy_sources.find(
(src) => src.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
const preferences: EnergyPreferences = { if (!gridSource) {
...this.preferences, preferences = {
energy_sources: this.preferences.energy_sources.map((src) => ...this.preferences,
src.type === "grid" ? { ...src, flow_to: [...flowTo, source] } : src energy_sources: [
), ...this.preferences.energy_sources,
}; {
...emptyGridSourceEnergyPreference(),
flow_to: [flow],
},
],
};
} else {
preferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? { ...src, flow_to: [...gridSource.flow_to, flow] }
: src
),
};
}
await this._savePreferences(preferences); await this._savePreferences(preferences);
}, },
}); });
@ -239,7 +285,6 @@ export class EnergyGridSettings extends LitElement {
const origSource: FlowFromGridSourceEnergyPreference = const origSource: FlowFromGridSourceEnergyPreference =
ev.currentTarget.closest(".row").source; ev.currentTarget.closest(".row").source;
showEnergySettingsGridFlowFromDialog(this, { showEnergySettingsGridFlowFromDialog(this, {
currency: this.preferences.currency,
source: { ...origSource }, source: { ...origSource },
saveCallback: async (source) => { saveCallback: async (source) => {
const flowFrom = energySourcesByType(this.preferences).grid![0] const flowFrom = energySourcesByType(this.preferences).grid![0]
@ -267,7 +312,6 @@ export class EnergyGridSettings extends LitElement {
const origSource: FlowToGridSourceEnergyPreference = const origSource: FlowToGridSourceEnergyPreference =
ev.currentTarget.closest(".row").source; ev.currentTarget.closest(".row").source;
showEnergySettingsGridFlowToDialog(this, { showEnergySettingsGridFlowToDialog(this, {
currency: this.preferences.currency,
source: { ...origSource }, source: { ...origSource },
saveCallback: async (source) => { saveCallback: async (source) => {
const flowTo = energySourcesByType(this.preferences).grid![0].flow_to; const flowTo = energySourcesByType(this.preferences).grid![0].flow_to;

View File

@ -19,6 +19,7 @@ import {
} from "../../../../dialogs/generic/show-dialog-box"; } from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles"; import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import { showEnergySettingsSolarDialog } from "../dialogs/show-dialogs-energy"; import { showEnergySettingsSolarDialog } from "../dialogs/show-dialogs-energy";
import { energyCardStyles } from "./styles"; import { energyCardStyles } from "./styles";
@ -37,14 +38,24 @@ export class EnergySolarSettings extends LitElement {
return html` return html`
<ha-card> <ha-card>
<h1 class="card-header"> <h1 class="card-header">
<ha-svg-icon .path=${mdiSolarPower}></ha-svg-icon>Configure solar <ha-svg-icon .path=${mdiSolarPower}></ha-svg-icon>
panels ${this.hass.localize("ui.panel.config.energy.solar.title")}
</h1> </h1>
<div class="card-content"> <div class="card-content">
<p> <p>
Let Home Assistant monitor your solar panels and give you insight on ${this.hass.localize("ui.panel.config.energy.solar.sub")}
their performace. <a
target="_blank"
rel="noopener noreferrer"
href="${documentationUrl(
this.hass,
"/docs/energy/solar-panels/"
)}"
>${this.hass.localize(
"ui.panel.config.energy.solar.learn_more"
)}</a
>
</p> </p>
<h3>Solar production</h3> <h3>Solar production</h3>
${solarSources.map((source) => { ${solarSources.map((source) => {

View File

@ -58,12 +58,11 @@ export class DialogEnergyDeviceSettings
@closed=${this.closeDialog} @closed=${this.closeDialog}
> >
${this._error ? html`<p class="error">${this._error}</p>` : ""} ${this._error ? html`<p class="error">${this._error}</p>` : ""}
<p>Track your devices <a href="#">Learn more</a></p>
<ha-statistic-picker <ha-statistic-picker
.hass=${this.hass} .hass=${this.hass}
.includeUnitOfMeasurement=${energyUnits} .includeUnitOfMeasurement=${energyUnits}
.label=${`Device production energy (kWh)`} .label=${`Device consumption energy (kWh)`}
entities-only entities-only
@value-changed=${this._statisticChanged} @value-changed=${this._statisticChanged}
></ha-statistic-picker> ></ha-statistic-picker>

View File

@ -85,11 +85,11 @@ export class DialogEnergyGridFlowSettings
@closed=${this.closeDialog} @closed=${this.closeDialog}
> >
${this._error ? html`<p class="error">${this._error}</p>` : ""} ${this._error ? html`<p class="error">${this._error}</p>` : ""}
<p> <div>
${this.hass.localize( ${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.paragraph` `ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.paragraph`
)} )}
</p> </div>
<ha-statistic-picker <ha-statistic-picker
.hass=${this.hass} .hass=${this.hass}
@ -203,7 +203,7 @@ export class DialogEnergyGridFlowSettings
<span slot="suffix" <span slot="suffix"
>${this.hass.localize( >${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_number_suffix`, `ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_number_suffix`,
{ currency: this._params.currency } { currency: this.hass.config.currency }
)}</span )}</span
> >
</paper-input>` </paper-input>`
@ -212,7 +212,15 @@ export class DialogEnergyGridFlowSettings
<mwc-button @click=${this.closeDialog} slot="secondaryAction"> <mwc-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass.localize("ui.common.cancel")} ${this.hass.localize("ui.common.cancel")}
</mwc-button> </mwc-button>
<mwc-button @click=${this._save} slot="primaryAction"> <mwc-button
@click=${this._save}
.disabled=${!this._source[
this._params!.direction === "from"
? "stat_energy_from"
: "stat_energy_to"
]}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")} ${this.hass.localize("ui.common.save")}
</mwc-button> </mwc-button>
</ha-dialog> </ha-dialog>
@ -231,32 +239,42 @@ export class DialogEnergyGridFlowSettings
} }
private _numberPriceChanged(ev: CustomEvent) { private _numberPriceChanged(ev: CustomEvent) {
this._source!.number_energy_price = Number(ev.detail.value);
this._source!.entity_energy_price = null;
this._costStat = null; this._costStat = null;
this._source = {
...this._source!,
number_energy_price: Number(ev.detail.value),
entity_energy_price: null,
};
} }
private _priceStatChanged(ev: CustomEvent) { private _priceStatChanged(ev: CustomEvent) {
this._costStat = ev.detail.value; this._costStat = ev.detail.value;
this._source!.entity_energy_price = null; this._source = {
this._source!.number_energy_price = null; ...this._source!,
entity_energy_price: null,
number_energy_price: null,
};
} }
private _priceEntityChanged(ev: CustomEvent) { private _priceEntityChanged(ev: CustomEvent) {
this._source!.entity_energy_price = ev.detail.value;
this._source!.number_energy_price = null;
this._costStat = null; this._costStat = null;
this._source = {
...this._source!,
entity_energy_price: ev.detail.value,
number_energy_price: null,
};
} }
private _statisticChanged(ev: CustomEvent<{ value: string }>) { private _statisticChanged(ev: CustomEvent<{ value: string }>) {
this._source![ this._source = {
this._params!.direction === "from" ? "stat_energy_from" : "stat_energy_to" ...this._source!,
] = ev.detail.value; [this._params!.direction === "from"
this._source![ ? "stat_energy_from"
this._params!.direction === "from" : "stat_energy_to"]: ev.detail.value,
[this._params!.direction === "from"
? "entity_energy_from" ? "entity_energy_from"
: "entity_energy_to" : "entity_energy_to"]: ev.detail.value,
] = ev.detail.value; };
} }
private async _save() { private async _save() {
@ -277,6 +295,9 @@ export class DialogEnergyGridFlowSettings
return [ return [
haStyleDialog, haStyleDialog,
css` css`
ha-dialog {
--mdc-dialog-max-width: 430px;
}
ha-formfield { ha-formfield {
display: block; display: block;
} }

View File

@ -75,7 +75,6 @@ export class DialogEnergySolarSettings
@closed=${this.closeDialog} @closed=${this.closeDialog}
> >
${this._error ? html`<p class="error">${this._error}</p>` : ""} ${this._error ? html`<p class="error">${this._error}</p>` : ""}
<p>Solar production for the win! <a href="#">Learn more</a></p>
<ha-statistic-picker <ha-statistic-picker
.hass=${this.hass} .hass=${this.hass}
@ -141,7 +140,11 @@ export class DialogEnergySolarSettings
<mwc-button @click=${this.closeDialog} slot="secondaryAction"> <mwc-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass.localize("ui.common.cancel")} ${this.hass.localize("ui.common.cancel")}
</mwc-button> </mwc-button>
<mwc-button @click=${this._save} slot="primaryAction"> <mwc-button
@click=${this._save}
.disabled=${!this._source.stat_energy_from}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")} ${this.hass.localize("ui.common.save")}
</mwc-button> </mwc-button>
</ha-dialog> </ha-dialog>
@ -192,7 +195,7 @@ export class DialogEnergySolarSettings
} }
private _statisticChanged(ev: CustomEvent<{ value: string }>) { private _statisticChanged(ev: CustomEvent<{ value: string }>) {
this._source!.stat_energy_from = ev.detail.value; this._source = { ...this._source!, stat_energy_from: ev.detail.value };
} }
private async _save() { private async _save() {
@ -212,6 +215,9 @@ export class DialogEnergySolarSettings
haStyle, haStyle,
haStyleDialog, haStyleDialog,
css` css`
ha-dialog {
--mdc-dialog-max-width: 430px;
}
img { img {
height: 24px; height: 24px;
margin-right: 16px; margin-right: 16px;

View File

@ -10,7 +10,6 @@ export interface EnergySettingsGridFlowDialogParams {
source?: source?:
| FlowFromGridSourceEnergyPreference | FlowFromGridSourceEnergyPreference
| FlowToGridSourceEnergyPreference; | FlowToGridSourceEnergyPreference;
currency: string;
direction: "from" | "to"; direction: "from" | "to";
saveCallback: ( saveCallback: (
source: source:
@ -21,13 +20,11 @@ export interface EnergySettingsGridFlowDialogParams {
export interface EnergySettingsGridFlowFromDialogParams { export interface EnergySettingsGridFlowFromDialogParams {
source?: FlowFromGridSourceEnergyPreference; source?: FlowFromGridSourceEnergyPreference;
currency: string;
saveCallback: (source: FlowFromGridSourceEnergyPreference) => Promise<void>; saveCallback: (source: FlowFromGridSourceEnergyPreference) => Promise<void>;
} }
export interface EnergySettingsGridFlowToDialogParams { export interface EnergySettingsGridFlowToDialogParams {
source?: FlowToGridSourceEnergyPreference; source?: FlowToGridSourceEnergyPreference;
currency: string;
saveCallback: (source: FlowToGridSourceEnergyPreference) => Promise<void>; saveCallback: (source: FlowToGridSourceEnergyPreference) => Promise<void>;
} }

View File

@ -1,25 +1,17 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import { import { EnergyPreferences, getEnergyPreferences } from "../../../data/energy";
EnergyPreferences,
getEnergyPreferences,
saveEnergyPreferences,
} from "../../../data/energy";
import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage"; import "../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types"; import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import "./components/ha-energy-device-settings";
import "./components/ha-energy-grid-settings"; import "./components/ha-energy-grid-settings";
import "./components/ha-energy-solar-settings"; import "./components/ha-energy-solar-settings";
import "./components/ha-energy-device-settings";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
const INITIAL_CONFIG = { const INITIAL_CONFIG: EnergyPreferences = {
currency: "€",
energy_sources: [], energy_sources: [],
device_consumption: [], device_consumption: [],
}; };
@ -72,18 +64,6 @@ class HaConfigEnergy extends LitElement {
.route=${this.route} .route=${this.route}
.tabs=${configSections.experiences} .tabs=${configSections.experiences}
> >
<ha-card .header=${"General energy settings"}>
<div class="card-content">
<paper-input
.label=${"Currency"}
.value=${this._preferences!.currency}
@value-changed=${this._currencyChanged}
>
</paper-input>
<mwc-button @click=${this._save}>Save</mwc-button>
</div>
</ha-card>
<div class="container"> <div class="container">
<ha-energy-grid-settings <ha-energy-grid-settings
.hass=${this.hass} .hass=${this.hass}
@ -105,24 +85,6 @@ class HaConfigEnergy extends LitElement {
`; `;
} }
private _currencyChanged(ev: CustomEvent) {
this._preferences!.currency = ev.detail.value;
}
private async _save() {
if (!this._preferences) {
return;
}
try {
this._preferences = await saveEnergyPreferences(
this.hass,
this._preferences
);
} catch (err) {
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
}
}
private async _fetchConfig() { private async _fetchConfig() {
try { try {
this._preferences = await getEnergyPreferences(this.hass); this._preferences = await getEnergyPreferences(this.hass);

View File

@ -0,0 +1,273 @@
import "../../../../../components/ha-circular-progress";
import "@material/mwc-button/mwc-button";
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiStethoscope, mdiCheckCircle, mdiCloseCircle } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { createCloseHeading } from "../../../../../components/ha-dialog";
import {
DeviceRegistryEntry,
computeDeviceName,
} from "../../../../../data/device_registry";
import {
fetchNetworkStatus,
healNode,
ZWaveJSNetwork,
} from "../../../../../data/zwave_js";
import { haStyleDialog } from "../../../../../resources/styles";
import { HomeAssistant } from "../../../../../types";
import { ZWaveJSHealNodeDialogParams } from "./show-dialog-zwave_js-heal-node";
@customElement("dialog-zwave_js-heal-node")
class DialogZWaveJSHealNode extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private entry_id?: string;
@state() private node_id?: number;
@state() private device?: DeviceRegistryEntry;
@state() private _status?: string;
@state() private _error?: string;
public showDialog(params: ZWaveJSHealNodeDialogParams): void {
this.entry_id = params.entry_id;
this.device = params.device;
this.node_id = params.node_id;
this._fetchData();
}
public closeDialog(): void {
this.entry_id = undefined;
this._status = undefined;
this.node_id = undefined;
this.device = undefined;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
if (!this.entry_id || !this.device) {
return html``;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize("ui.panel.config.zwave_js.heal_node.title")
)}
>
${!this._status
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiStethoscope}
class="introduction"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_node.introduction",
{
device: html`<em
>${computeDeviceName(this.device, this.hass!)}</em
>`,
}
)}
</p>
</div>
</div>
<p>
<em>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_node.traffic_warning"
)}
</em>
</p>
<mwc-button slot="primaryAction" @click=${this._startHeal}>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_node.start_heal"
)}
</mwc-button>
`
: ``}
${this._status === "started"
? html`
<div class="flex-container">
<ha-circular-progress active></ha-circular-progress>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_node.in_progress",
{
device: html`<em
>${computeDeviceName(this.device, this.hass!)}</em
>`,
}
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</mwc-button>
`
: ``}
${this._status === "failed"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="failed"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_node.healing_failed",
{
device: html`<em
>${computeDeviceName(this.device, this.hass!)}</em
>`,
}
)}
</p>
<p>
${this._error
? html` <em>${this._error}</em> `
: `
${this.hass.localize(
"ui.panel.config.zwave_js.heal_node.healing_failed_check_logs"
)}
`}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</mwc-button>
`
: ``}
${this._status === "finished"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_node.healing_complete",
{
device: html`<em
>${computeDeviceName(this.device, this.hass!)}</em
>`,
}
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.panel.config.zwave_js.common.close")}
</mwc-button>
`
: ``}
${this._status === "network-healing"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="failed"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.heal_node.network_heal_in_progress"
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.panel.config.zwave_js.common.close")}
</mwc-button>
`
: ``}
</ha-dialog>
`;
}
private async _fetchData(): Promise<void> {
if (!this.hass) {
return;
}
const network: ZWaveJSNetwork = await fetchNetworkStatus(
this.hass!,
this.entry_id!
);
if (network.controller.is_heal_network_active) {
this._status = "network-healing";
}
}
private async _startHeal(): Promise<void> {
if (!this.hass) {
return;
}
this._status = "started";
try {
this._status = (await healNode(this.hass, this.entry_id!, this.node_id!))
? "finished"
: "failed";
} catch (error) {
this._error = error.message;
this._status = "failed";
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
.success {
color: var(--success-color);
}
.failed {
color: var(--error-color);
}
.flex-container {
display: flex;
align-items: center;
}
ha-svg-icon {
width: 68px;
height: 48px;
}
ha-svg-icon.introduction {
color: var(--primary-color);
}
.flex-container ha-svg-icon,
.flex-container ha-circular-progress {
margin-right: 20px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-zwave_js-heal-node": DialogZWaveJSHealNode;
}
}

View File

@ -0,0 +1,237 @@
import "@material/mwc-button/mwc-button";
import { mdiCheckCircle, mdiCloseCircle, mdiRobotDead } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-circular-progress";
import { createCloseHeading } from "../../../../../components/ha-dialog";
import {
removeFailedNode,
ZWaveJSRemovedNode,
} from "../../../../../data/zwave_js";
import { haStyleDialog } from "../../../../../resources/styles";
import { HomeAssistant } from "../../../../../types";
import { ZWaveJSRemoveFailedNodeDialogParams } from "./show-dialog-zwave_js-remove-failed-node";
@customElement("dialog-zwave_js-remove-failed-node")
class DialogZWaveJSRemoveFailedNode extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private entry_id?: string;
@state() private node_id?: number;
@state() private _status = "";
@state() private _error?: any;
@state() private _node?: ZWaveJSRemovedNode;
private _subscribed?: Promise<UnsubscribeFunc | void>;
public disconnectedCallback(): void {
super.disconnectedCallback();
this._unsubscribe();
}
public async showDialog(
params: ZWaveJSRemoveFailedNodeDialogParams
): Promise<void> {
this.entry_id = params.entry_id;
this.node_id = params.node_id;
}
public closeDialog(): void {
this._unsubscribe();
this.entry_id = undefined;
this._status = "";
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialogFinished(): void {
history.back();
this.closeDialog();
}
protected render(): TemplateResult {
if (!this.entry_id || !this.node_id) {
return html``;
}
return html`
<ha-dialog
open
@closed="${this.closeDialog}"
.heading=${createCloseHeading(
this.hass,
this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.title"
)
)}
>
${this._status === ""
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiRobotDead}
class="introduction"
></ha-svg-icon>
<div class="status">
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.introduction"
)}
</div>
</div>
<mwc-button slot="primaryAction" @click=${this._startExclusion}>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.remove_device"
)}
</mwc-button>
`
: ``}
${this._status === "started"
? html`
<div class="flex-container">
<ha-circular-progress active></ha-circular-progress>
<div class="status">
<p>
<b>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.in_progress"
)}
</b>
</p>
</div>
</div>
`
: ``}
${this._status === "failed"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="error"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.removal_failed"
)}
</p>
${this._error
? html` <p><em> ${this._error.message} </em></p> `
: ``}
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</mwc-button>
`
: ``}
${this._status === "finished"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.removal_finished",
"id",
this._node!.node_id
)}
</p>
</div>
</div>
<mwc-button
slot="primaryAction"
@click=${this.closeDialogFinished}
>
${this.hass.localize("ui.common.close")}
</mwc-button>
`
: ``}
</ha-dialog>
`;
}
private _startExclusion(): void {
if (!this.hass) {
return;
}
this._status = "started";
this._subscribed = removeFailedNode(
this.hass,
this.entry_id!,
this.node_id!,
(message: any) => this._handleMessage(message)
).catch((error) => {
this._status = "failed";
this._error = error;
});
}
private _handleMessage(message: any): void {
if (message.event === "exclusion started") {
this._status = "started";
}
if (message.event === "node removed") {
this._status = "finished";
this._node = message.node;
this._unsubscribe();
}
}
private async _unsubscribe(): Promise<void> {
if (this._subscribed) {
const unsubFunc = await this._subscribed;
if (unsubFunc instanceof Function) {
unsubFunc();
}
this._subscribed = undefined;
}
if (this._status !== "finished") {
this._status = "";
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
.success {
color: var(--success-color);
}
.failed {
color: var(--warning-color);
}
.flex-container {
display: flex;
align-items: center;
}
ha-svg-icon {
width: 68px;
height: 48px;
}
.flex-container ha-circular-progress,
.flex-container ha-svg-icon {
margin-right: 20px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-zwave_js-remove-failed-node": DialogZWaveJSRemoveFailedNode;
}
}

View File

@ -0,0 +1,21 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
import { DeviceRegistryEntry } from "../../../../../data/device_registry";
export interface ZWaveJSHealNodeDialogParams {
entry_id: string;
node_id: number;
device: DeviceRegistryEntry;
}
export const loadHealNodeDialog = () => import("./dialog-zwave_js-heal-node");
export const showZWaveJSHealNodeDialog = (
element: HTMLElement,
healNodeDialogParams: ZWaveJSHealNodeDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-zwave_js-heal-node",
dialogImport: loadHealNodeDialog,
dialogParams: healNodeDialogParams,
});
};

View File

@ -0,0 +1,20 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
export interface ZWaveJSRemoveFailedNodeDialogParams {
entry_id: string;
node_id: number;
}
export const loadRemoveFailedNodeDialog = () =>
import("./dialog-zwave_js-remove-failed-node");
export const showZWaveJSRemoveFailedNodeDialog = (
element: HTMLElement,
removeFailedNodeDialogParams: ZWaveJSRemoveFailedNodeDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-zwave_js-remove-failed-node",
dialogImport: loadRemoveFailedNodeDialog,
dialogParams: removeFailedNodeDialogParams,
});
};

View File

@ -131,7 +131,9 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
private _downloadLogs() { private _downloadLogs() {
fileDownload( fileDownload(
this, this,
`data:text/plain;charset=utf-8,${encodeURI(this._textarea!.value)}`, `data:text/plain;charset=utf-8,${encodeURIComponent(
this._textarea!.value
)}`,
`zwave_js.log` `zwave_js.log`
); );
} }

View File

@ -9,7 +9,8 @@ import { computeDomain } from "../../../common/entity/compute_domain";
import { computeObjectId } from "../../../common/entity/compute_object_id"; import { computeObjectId } from "../../../common/entity/compute_object_id";
import { hasTemplate } from "../../../common/string/has-template"; import { hasTemplate } from "../../../common/string/has-template";
import { extractSearchParam } from "../../../common/url/search-params"; import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/buttons/ha-progress-button"; import { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import "../../../components/entity/ha-entity-picker"; import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-expansion-panel"; import "../../../components/ha-expansion-panel";
@ -135,11 +136,15 @@ class HaPanelDevService extends LitElement {
>` >`
: ""} : ""}
</div> </div>
<mwc-button .disabled=${!isValid} raised @click=${this._callService}> <ha-progress-button
.disabled=${!isValid}
raised
@click=${this._callService}
>
${this.hass.localize( ${this.hass.localize(
"ui.panel.developer-tools.tabs.services.call_service" "ui.panel.developer-tools.tabs.services.call_service"
)} )}
</mwc-button> </ha-progress-button>
</div> </div>
</div> </div>
@ -295,7 +300,8 @@ class HaPanelDevService extends LitElement {
} }
); );
private async _callService() { private async _callService(ev) {
const button = ev.currentTarget as HaProgressButton;
if (!this._serviceData?.service) { if (!this._serviceData?.service) {
return; return;
} }
@ -310,6 +316,7 @@ class HaPanelDevService extends LitElement {
return; return;
} }
forwardHaptic("failure"); forwardHaptic("failure");
button.actionError();
showToast(this, { showToast(this, {
message: message:
this.hass.localize( this.hass.localize(
@ -318,7 +325,9 @@ class HaPanelDevService extends LitElement {
this._serviceData.service this._serviceData.service
) + ` ${err.message}`, ) + ` ${err.message}`,
}); });
return;
} }
button.actionSuccess();
} }
private _toggleYaml() { private _toggleYaml() {

View File

@ -20,8 +20,7 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
@state() private _step = 0; @state() private _step = 0;
private _preferences: EnergyPreferences = { @state() private _preferences: EnergyPreferences = {
currency: "€",
energy_sources: [], energy_sources: [],
device_consumption: [], device_consumption: [],
}; };
@ -42,9 +41,6 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<h2>${this.hass.localize("ui.panel.energy.setup.header")}</h2>
<h3>${this.hass.localize("ui.panel.energy.setup.slogan")}</h3>
<p>Step ${this._step + 1} of 3</p> <p>Step ${this._step + 1} of 3</p>
${this._step === 0 ${this._step === 0
? html` <ha-energy-grid-settings ? html` <ha-energy-grid-settings
@ -65,15 +61,15 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
></ha-energy-device-settings>`} ></ha-energy-device-settings>`}
<div class="buttons"> <div class="buttons">
${this._step > 0 ${this._step > 0
? html`<mwc-button @click=${this._back} ? html`<mwc-button outlined @click=${this._back}
>${this.hass.localize("ui.panel.energy.setup.back")}</mwc-button >${this.hass.localize("ui.panel.energy.setup.back")}</mwc-button
>` >`
: html`<div></div>`} : html`<div></div>`}
${this._step < 2 ${this._step < 2
? html`<mwc-button outlined @click=${this._next} ? html`<mwc-button unelevated @click=${this._next}
>${this.hass.localize("ui.panel.energy.setup.next")}</mwc-button >${this.hass.localize("ui.panel.energy.setup.next")}</mwc-button
>` >`
: html`<mwc-button raised @click=${this._setupDone}> : html`<mwc-button unelevated @click=${this._setupDone}>
${this.hass.localize("ui.panel.energy.setup.done")} ${this.hass.localize("ui.panel.energy.setup.done")}
</mwc-button>`} </mwc-button>`}
</div> </div>

View File

@ -1,4 +1,8 @@
import { EnergyPreferences, getEnergyPreferences } from "../../../data/energy"; import {
EnergyPreferences,
getEnergyPreferences,
GridSourceTypeEnergyPreference,
} from "../../../data/energy";
import { LovelaceViewConfig } from "../../../data/lovelace"; import { LovelaceViewConfig } from "../../../data/lovelace";
import { LovelaceViewStrategy } from "../../lovelace/strategies/get-strategy"; import { LovelaceViewStrategy } from "../../lovelace/strategies/get-strategy";
@ -39,9 +43,10 @@ export class EnergyStrategy {
view.type = "sidebar"; view.type = "sidebar";
const hasGrid = energyPrefs.energy_sources.some( const hasGrid = energyPrefs.energy_sources.find(
(source) => source.type === "grid" (source) => source.type === "grid"
); ) as GridSourceTypeEnergyPreference;
const hasReturn = hasGrid && hasGrid.flow_to.length;
const hasSolar = energyPrefs.energy_sources.some( const hasSolar = energyPrefs.energy_sources.some(
(source) => source.type === "solar" (source) => source.type === "solar"
); );
@ -49,8 +54,8 @@ export class EnergyStrategy {
// Only include if we have a grid source. // Only include if we have a grid source.
if (hasGrid) { if (hasGrid) {
view.cards!.push({ view.cards!.push({
title: "Electricity", title: "Energy usage",
type: "energy-summary-graph", type: "energy-usage-graph",
prefs: energyPrefs, prefs: energyPrefs,
}); });
} }
@ -67,30 +72,21 @@ export class EnergyStrategy {
// Only include if we have a grid. // Only include if we have a grid.
if (hasGrid) { if (hasGrid) {
view.cards!.push({ view.cards!.push({
title: "Costs", title: "Energy distribution",
type: "energy-costs-table", type: "energy-distribution",
prefs: energyPrefs,
});
}
// Only include if we have at least 1 device in the config.
if (energyPrefs.device_consumption.length) {
view.cards!.push({
title: "Monitor individual devices",
type: "energy-devices-graph",
prefs: energyPrefs,
});
}
// Only include if we have a grid.
if (hasGrid) {
view.cards!.push({
type: "energy-usage",
prefs: energyPrefs, prefs: energyPrefs,
view_layout: { position: "sidebar" }, view_layout: { position: "sidebar" },
}); });
} }
if (hasGrid || hasSolar) {
view.cards!.push({
title: "Sources",
type: "energy-sources-table",
prefs: energyPrefs,
});
}
// Only include if we have a solar source. // Only include if we have a solar source.
if (hasSolar) { if (hasSolar) {
view.cards!.push({ view.cards!.push({
@ -100,6 +96,15 @@ export class EnergyStrategy {
}); });
} }
// Only include if we have a grid source & return.
if (hasReturn) {
view.cards!.push({
type: "energy-grid-neutrality-gauge",
prefs: energyPrefs,
view_layout: { position: "sidebar" },
});
}
// Only include if we have a grid // Only include if we have a grid
if (hasGrid) { if (hasGrid) {
view.cards!.push({ view.cards!.push({
@ -109,11 +114,14 @@ export class EnergyStrategy {
}); });
} }
view.cards!.push({ // Only include if we have at least 1 device in the config.
type: "energy-summary", if (energyPrefs.device_consumption.length) {
prefs: energyPrefs, view.cards!.push({
view_layout: { position: "sidebar" }, title: "Monitor individual devices",
}); type: "energy-devices-graph",
prefs: energyPrefs,
});
}
return view; return view;
} }

View File

@ -1,22 +1,24 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { round } from "../../../common/number/round"; import { round } from "../../../../common/number/round";
import { subscribeOne } from "../../../common/util/subscribe-one"; import { subscribeOne } from "../../../../common/util/subscribe-one";
import "../../../components/ha-card"; import "../../../../components/ha-card";
import "../../../components/ha-gauge"; import "../../../../components/ha-gauge";
import { getConfigEntries } from "../../../data/config_entries"; import { getConfigEntries } from "../../../../data/config_entries";
import { energySourcesByType } from "../../../data/energy"; import { energySourcesByType } from "../../../../data/energy";
import { subscribeEntityRegistry } from "../../../data/entity_registry"; import { subscribeEntityRegistry } from "../../../../data/entity_registry";
import { import {
calculateStatisticsSumGrowth, calculateStatisticsSumGrowth,
calculateStatisticsSumGrowthWithPercentage,
fetchStatistics, fetchStatistics,
Statistics, Statistics,
} from "../../../data/history"; } from "../../../../data/history";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../types"; import { createEntityNotFoundWarning } from "../../components/hui-warning";
import { severityMap } from "./hui-gauge-card"; import type { LovelaceCard } from "../../types";
import type { EnergyCarbonGaugeCardConfig } from "./types"; import { severityMap } from "../hui-gauge-card";
import type { EnergyCarbonGaugeCardConfig } from "../types";
@customElement("hui-energy-carbon-consumed-gauge-card") @customElement("hui-energy-carbon-consumed-gauge-card")
class HuiEnergyCarbonGaugeCard extends LitElement implements LovelaceCard { class HuiEnergyCarbonGaugeCard extends LitElement implements LovelaceCard {
@ -41,7 +43,6 @@ class HuiEnergyCarbonGaugeCard extends LitElement implements LovelaceCard {
if (!this.hasUpdated) { if (!this.hasUpdated) {
this._getStatistics(); this._getStatistics();
this._fetchCO2SignalEntity();
} }
} }
@ -50,24 +51,20 @@ class HuiEnergyCarbonGaugeCard extends LitElement implements LovelaceCard {
return html``; return html``;
} }
if (!this._stats || this._co2SignalEntity === undefined) { if (this._co2SignalEntity === null) {
return html`Loading...`; return html``;
} }
if (!this._co2SignalEntity) { if (!this._stats || !this._co2SignalEntity) {
return html``; return html`Loading...`;
} }
const co2State = this.hass.states[this._co2SignalEntity]; const co2State = this.hass.states[this._co2SignalEntity];
if (!co2State) { if (!co2State) {
return html`No CO2 Signal entity found.`; return html`<hui-warning>
} ${createEntityNotFoundWarning(this.hass, this._co2SignalEntity)}
</hui-warning>`;
const co2percentage = Number(co2State.state);
if (isNaN(co2percentage)) {
return html``;
} }
const prefs = this._config!.prefs; const prefs = this._config!.prefs;
@ -78,56 +75,65 @@ class HuiEnergyCarbonGaugeCard extends LitElement implements LovelaceCard {
types.grid![0].flow_from.map((flow) => flow.stat_energy_from) types.grid![0].flow_from.map((flow) => flow.stat_energy_from)
); );
const totalSolarProduction = types.solar let value: number | undefined;
? calculateStatisticsSumGrowth(
this._stats,
types.solar.map((source) => source.stat_energy_from)
)
: undefined;
const totalGridReturned = calculateStatisticsSumGrowth( if (this._co2SignalEntity in this._stats && totalGridConsumption) {
this._stats, const highCarbonEnergy =
types.grid![0].flow_to.map((flow) => flow.stat_energy_to) calculateStatisticsSumGrowthWithPercentage(
); this._stats[this._co2SignalEntity],
types
.grid![0].flow_from.map(
(flow) => this._stats![flow.stat_energy_from]
)
.filter(Boolean)
) || 0;
if (totalGridConsumption === null) { const totalSolarProduction = types.solar
return html`Couldn't calculate the total grid consumption.`; ? calculateStatisticsSumGrowth(
this._stats,
types.solar.map((source) => source.stat_energy_from)
)
: undefined;
const totalGridReturned = calculateStatisticsSumGrowth(
this._stats,
types.grid![0].flow_to.map((flow) => flow.stat_energy_to)
);
const totalEnergyConsumed =
totalGridConsumption +
Math.max(0, (totalSolarProduction || 0) - (totalGridReturned || 0));
value = round((1 - highCarbonEnergy / totalEnergyConsumed) * 100);
} }
const highCarbonEnergy = (totalGridConsumption * co2percentage) / 100;
const totalEnergyConsumed =
totalGridConsumption +
(totalSolarProduction || 0) -
(totalGridReturned || 0);
const value = round((highCarbonEnergy / totalEnergyConsumed) * 100);
return html` return html`
<ha-card> <ha-card
<ha-gauge >${value !== undefined
min="0" ? html` <ha-gauge
max="100" min="0"
.value=${value} max="100"
.locale=${this.hass!.locale} .value=${value}
label="%" .locale=${this.hass!.locale}
style=${styleMap({ label="%"
"--gauge-color": this._computeSeverity(64), style=${styleMap({
})} "--gauge-color": this._computeSeverity(value),
></ha-gauge> })}
<div class="name">High-carbon energy consumed</div> ></ha-gauge>
<div class="name">Non-fossil energy consumed</div>`
: html`Consumed non-fossil energy couldn't be calculated`}
</ha-card> </ha-card>
`; `;
} }
private _computeSeverity(numberValue: number): string { private _computeSeverity(numberValue: number): string {
if (numberValue > 50) { if (numberValue < 10) {
return severityMap.red; return severityMap.red;
} }
if (numberValue > 30) { if (numberValue < 30) {
return severityMap.yellow; return severityMap.yellow;
} }
if (numberValue < 10) { if (numberValue > 75) {
return severityMap.green; return severityMap.green;
} }
return severityMap.normal; return severityMap.normal;
@ -166,6 +172,12 @@ class HuiEnergyCarbonGaugeCard extends LitElement implements LovelaceCard {
} }
private async _getStatistics(): Promise<void> { private async _getStatistics(): Promise<void> {
await this._fetchCO2SignalEntity();
if (this._co2SignalEntity === null) {
return;
}
const startDate = new Date(); const startDate = new Date();
startDate.setHours(0, 0, 0, 0); startDate.setHours(0, 0, 0, 0);
startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint
@ -187,6 +199,10 @@ class HuiEnergyCarbonGaugeCard extends LitElement implements LovelaceCard {
} }
} }
if (this._co2SignalEntity) {
statistics.push(this._co2SignalEntity);
}
this._stats = await fetchStatistics( this._stats = await fetchStatistics(
this.hass!, this.hass!,
startDate, startDate,

View File

@ -14,18 +14,22 @@ import {
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { getColorByIndex } from "../../../common/color/colors"; import { getColorByIndex } from "../../../../common/color/colors";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../../common/entity/compute_state_name";
import "../../../components/chart/ha-chart-base"; import {
import "../../../components/ha-card"; formatNumber,
numberFormatToLocale,
} from "../../../../common/string/format_number";
import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import { import {
calculateStatisticSumGrowth, calculateStatisticSumGrowth,
fetchStatistics, fetchStatistics,
Statistics, Statistics,
} from "../../../data/history"; } from "../../../../data/history";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../../types";
import { LovelaceCard } from "../types"; import { LovelaceCard } from "../../types";
import { EnergyDevicesGraphCardConfig } from "./types"; import { EnergyDevicesGraphCardConfig } from "../types";
@customElement("hui-energy-devices-graph-card") @customElement("hui-energy-devices-graph-card")
export class HuiEnergyDevicesGraphCard export class HuiEnergyDevicesGraphCard
@ -106,7 +110,10 @@ export class HuiEnergyDevicesGraphCard
} }
return html` return html`
<ha-card .header="${this._config.title}"> <ha-card>
${this._config.title
? html`<h1 class="card-header">${this._config.title}</h1>`
: ""}
<div <div
class="content ${classMap({ class="content ${classMap({
"has-header": !!this._config.title, "has-header": !!this._config.title,
@ -138,18 +145,21 @@ export class HuiEnergyDevicesGraphCard
}, },
}, },
}, },
elements: { bar: { borderWidth: 1.5 } }, elements: { bar: { borderWidth: 1.5, borderRadius: 4 } },
plugins: { plugins: {
tooltip: { tooltip: {
mode: "nearest", mode: "nearest",
callbacks: { callbacks: {
label: (context) => label: (context) =>
`${context.dataset.label}: ${ `${context.dataset.label}: ${formatNumber(
Math.round(context.parsed.x * 100) / 100 context.parsed.x,
} kWh`, this.hass.locale
)} kWh`,
}, },
}, },
}, },
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
}; };
} }
@ -178,10 +188,6 @@ export class HuiEnergyDevicesGraphCard
const statisticsData = Object.values(this._data!); const statisticsData = Object.values(this._data!);
let endTime: Date; let endTime: Date;
if (statisticsData.length === 0) {
return;
}
endTime = new Date( endTime = new Date(
Math.max( Math.max(
...statisticsData.map((stats) => ...statisticsData.map((stats) =>
@ -190,7 +196,7 @@ export class HuiEnergyDevicesGraphCard
) )
); );
if (endTime > new Date()) { if (!endTime || endTime > new Date()) {
endTime = new Date(); endTime = new Date();
} }
@ -207,27 +213,30 @@ export class HuiEnergyDevicesGraphCard
}, },
]; ];
Object.entries(this._data).forEach(([id, statistics], idx) => { for (let idx = 0; idx < prefs.device_consumption.length; idx++) {
const entity = this.hass.states[id]; const device = prefs.device_consumption[idx];
const label = entity ? computeStateName(entity) : id; const entity = this.hass.states[device.stat_consumption];
const label = entity ? computeStateName(entity) : device.stat_consumption;
const color = getColorByIndex(idx); const color = getColorByIndex(idx);
borderColor.push(color); borderColor.push(color);
backgroundColor.push(color + "7F"); backgroundColor.push(color + "7F");
const value = calculateStatisticSumGrowth(statistics); const value =
device.stat_consumption in this._data
? calculateStatisticSumGrowth(this._data[device.stat_consumption])
: 0;
data.push({ data.push({
// @ts-expect-error // @ts-expect-error
y: label, y: label,
x: value || 0, x: value || 0,
}); });
}); }
data.sort((a, b) => b.x - a.x); data.sort((a, b) => b.x - a.x);
this._chartData = { this._chartData = {
// labels,
datasets, datasets,
}; };
} }
@ -237,6 +246,9 @@ export class HuiEnergyDevicesGraphCard
ha-card { ha-card {
height: 100%; height: 100%;
} }
.card-header {
padding-bottom: 0;
}
.content { .content {
padding: 16px; padding: 16px;
} }

View File

@ -0,0 +1,625 @@
import {
mdiArrowLeft,
mdiArrowRight,
mdiHome,
mdiLeaf,
mdiSolarPower,
mdiTransmissionTower,
} from "@mdi/js";
import { css, html, LitElement, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { formatNumber } from "../../../../common/string/format_number";
import { subscribeOne } from "../../../../common/util/subscribe-one";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import { getConfigEntries } from "../../../../data/config_entries";
import { energySourcesByType } from "../../../../data/energy";
import { subscribeEntityRegistry } from "../../../../data/entity_registry";
import {
calculateStatisticsSumGrowth,
calculateStatisticsSumGrowthWithPercentage,
fetchStatistics,
Statistics,
} from "../../../../data/history";
import { HomeAssistant } from "../../../../types";
import { LovelaceCard } from "../../types";
import { EnergyDistributionCardConfig } from "../types";
const CIRCLE_CIRCUMFERENCE = 238.76104;
@customElement("hui-energy-distribution-card")
class HuiEnergyDistrubutionCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergyDistributionCardConfig;
@state() private _stats?: Statistics;
@state() private _co2SignalEntity?: string;
private _fetching = false;
public setConfig(config: EnergyDistributionCardConfig): void {
this._config = config;
}
public getCardSize(): Promise<number> | number {
return 3;
}
public willUpdate(changedProps) {
super.willUpdate(changedProps);
if (!this._fetching && !this._stats) {
this._fetching = true;
this._getStatistics().then(() => {
this._fetching = false;
});
}
}
protected render() {
if (!this._config) {
return html``;
}
if (!this._stats) {
return html`Loading…`;
}
const prefs = this._config!.prefs;
const types = energySourcesByType(prefs);
// The strategy only includes this card if we have a grid.
const hasConsumption = true;
const hasSolarProduction = types.solar !== undefined;
const hasReturnToGrid = hasConsumption && types.grid![0].flow_to.length > 0;
const totalGridConsumption =
calculateStatisticsSumGrowth(
this._stats,
types.grid![0].flow_from.map((flow) => flow.stat_energy_from)
) ?? 0;
let totalSolarProduction: number | null = null;
if (hasSolarProduction) {
totalSolarProduction =
calculateStatisticsSumGrowth(
this._stats,
types.solar!.map((source) => source.stat_energy_from)
) || 0;
}
let productionReturnedToGrid: number | null = null;
if (hasReturnToGrid) {
productionReturnedToGrid =
calculateStatisticsSumGrowth(
this._stats,
types.grid![0].flow_to.map((flow) => flow.stat_energy_to)
) || 0;
}
const solarConsumption = Math.max(
0,
(totalSolarProduction || 0) - (productionReturnedToGrid || 0)
);
const totalHomeConsumption = totalGridConsumption + solarConsumption;
let homeSolarCircumference: number | undefined;
if (hasSolarProduction) {
homeSolarCircumference =
CIRCLE_CIRCUMFERENCE * (solarConsumption / totalHomeConsumption);
}
let lowCarbonConsumption: number | undefined;
let homeLowCarbonCircumference: number | undefined;
let homeHighCarbonCircumference: number | undefined;
let electricityMapUrl: string | undefined;
if (this._co2SignalEntity && this._co2SignalEntity in this._stats) {
// Calculate high carbon consumption
const highCarbonConsumption = calculateStatisticsSumGrowthWithPercentage(
this._stats[this._co2SignalEntity],
types
.grid![0].flow_from.map((flow) => this._stats![flow.stat_energy_from])
.filter(Boolean)
);
const co2State = this.hass.states[this._co2SignalEntity];
if (co2State) {
electricityMapUrl = `https://www.electricitymap.org/zone/${co2State.attributes.country_code}`;
}
if (highCarbonConsumption !== null) {
lowCarbonConsumption = totalGridConsumption - highCarbonConsumption;
homeHighCarbonCircumference =
CIRCLE_CIRCUMFERENCE * (highCarbonConsumption / totalHomeConsumption);
homeLowCarbonCircumference =
CIRCLE_CIRCUMFERENCE -
(homeSolarCircumference || 0) -
homeHighCarbonCircumference;
}
}
return html`
<ha-card .header=${this._config.title}>
<div class="card-content">
${lowCarbonConsumption !== undefined || hasSolarProduction
? html`<div class="row">
${lowCarbonConsumption === undefined
? html`<div class="spacer"></div>`
: html`<div class="circle-container low-carbon">
<span class="label">Non-fossil</span>
<a
class="circle"
href=${ifDefined(electricityMapUrl)}
target="_blank"
rel="noopener no referrer"
>
<ha-svg-icon .path="${mdiLeaf}"></ha-svg-icon>
${lowCarbonConsumption
? formatNumber(
lowCarbonConsumption,
this.hass.locale,
{ maximumFractionDigits: 1 }
)
: "-"}
kWh
</a>
<svg width="80" height="30">
<line x1="40" y1="0" x2="40" y2="30"></line>
</svg>
</div>`}
${hasSolarProduction
? html`<div class="circle-container solar">
<span class="label">Solar</span>
<div class="circle">
<ha-svg-icon .path="${mdiSolarPower}"></ha-svg-icon>
${formatNumber(
totalSolarProduction || 0,
this.hass.locale,
{ maximumFractionDigits: 1 }
)}
kWh
</div>
</div>`
: ""}
<div class="spacer"></div>
</div>`
: ""}
<div class="row">
<div class="circle-container grid">
<div class="circle">
<ha-svg-icon .path="${mdiTransmissionTower}"></ha-svg-icon>
<span class="consumption">
${hasReturnToGrid
? html`<ha-svg-icon
class="small"
.path=${mdiArrowRight}
></ha-svg-icon>`
: ""}${formatNumber(
totalGridConsumption,
this.hass.locale,
{ maximumFractionDigits: 1 }
)}
kWh
</span>
${productionReturnedToGrid !== null
? html`<span class="return">
<ha-svg-icon
class="small"
.path=${mdiArrowLeft}
></ha-svg-icon
>${formatNumber(
productionReturnedToGrid,
this.hass.locale,
{ maximumFractionDigits: 1 }
)}
kWh
</span>`
: ""}
</div>
<span class="label">Grid</span>
</div>
<div class="circle-container home">
<div
class="circle ${classMap({
border:
homeSolarCircumference === undefined &&
homeLowCarbonCircumference === undefined,
})}"
>
<ha-svg-icon .path="${mdiHome}"></ha-svg-icon>
${formatNumber(totalHomeConsumption, this.hass.locale, {
maximumFractionDigits: 1,
})}
kWh
${homeSolarCircumference !== undefined ||
homeLowCarbonCircumference !== undefined
? html`<svg>
${homeSolarCircumference !== undefined
? svg`<circle
class="solar"
cx="40"
cy="40"
r="38"
stroke-dasharray="${homeSolarCircumference} ${
CIRCLE_CIRCUMFERENCE - homeSolarCircumference
}"
shape-rendering="geometricPrecision"
stroke-dashoffset="-${
CIRCLE_CIRCUMFERENCE - homeSolarCircumference
}"
/>`
: ""}
${homeLowCarbonCircumference
? svg`<circle
class="low-carbon"
cx="40"
cy="40"
r="38"
stroke-dasharray="${homeLowCarbonCircumference} ${
CIRCLE_CIRCUMFERENCE - homeLowCarbonCircumference
}"
stroke-dashoffset="-${
CIRCLE_CIRCUMFERENCE -
homeLowCarbonCircumference -
(homeSolarCircumference || 0)
}"
shape-rendering="geometricPrecision"
/>`
: ""}
<circle
class="grid"
cx="40"
cy="40"
r="38"
stroke-dasharray="${homeHighCarbonCircumference ??
CIRCLE_CIRCUMFERENCE -
homeSolarCircumference!} ${homeHighCarbonCircumference !==
undefined
? CIRCLE_CIRCUMFERENCE - homeHighCarbonCircumference
: homeSolarCircumference}"
stroke-dashoffset="0"
shape-rendering="geometricPrecision"
/>
</svg>`
: ""}
</div>
<span class="label">Home</span>
</div>
</div>
<div class="lines">
<svg
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid slice"
>
${hasReturnToGrid && hasSolarProduction
? svg`<path
id="return"
class="return"
d="M47,0 v15 c0,40 -10,35 -30,35 h-20"
vector-effect="non-scaling-stroke"
></path> `
: ""}
${hasSolarProduction
? svg`<path
id="solar"
class="solar"
d="M${
hasReturnToGrid ? 53 : 50
},0 v15 c0,40 10,35 30,35 h20"
vector-effect="non-scaling-stroke"
></path>`
: ""}
<path
class="grid"
id="grid"
d="M0,${hasSolarProduction ? 56 : 53} H100"
vector-effect="non-scaling-stroke"
></path>
${productionReturnedToGrid && hasSolarProduction
? svg`<circle
r="1"
class="return"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${
6 -
(productionReturnedToGrid /
(totalGridConsumption +
(totalSolarProduction || 0))) *
5
}s"
repeatCount="indefinite"
rotate="auto"
>
<mpath xlink:href="#return" />
</animateMotion>
</circle>`
: ""}
${totalSolarProduction
? svg`<circle
r="1"
class="solar"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${
6 -
((totalSolarProduction -
(productionReturnedToGrid || 0)) /
(totalGridConsumption +
(totalSolarProduction || 0))) *
5
}s"
repeatCount="indefinite"
rotate="auto"
>
<mpath xlink:href="#solar" />
</animateMotion>
</circle>`
: ""}
${totalGridConsumption
? svg`<circle
r="1"
class="grid"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${
6 -
(totalGridConsumption /
(totalGridConsumption +
(totalSolarProduction || 0))) *
5
}s"
repeatCount="indefinite"
rotate="auto"
>
<mpath xlink:href="#grid" />
</animateMotion>
</circle>`
: ""}
</svg>
</div>
</div>
</ha-card>
`;
}
private async _getStatistics(): Promise<void> {
const [configEntries, entityRegistryEntries] = await Promise.all([
getConfigEntries(this.hass),
subscribeOne(this.hass.connection, subscribeEntityRegistry),
]);
const co2ConfigEntry = configEntries.find(
(entry) => entry.domain === "co2signal"
);
this._co2SignalEntity = undefined;
if (co2ConfigEntry) {
for (const entry of entityRegistryEntries) {
if (entry.config_entry_id !== co2ConfigEntry.entry_id) {
continue;
}
// The integration offers 2 entities. We want the % one.
const co2State = this.hass.states[entry.entity_id];
if (!co2State || co2State.attributes.unit_of_measurement !== "%") {
continue;
}
this._co2SignalEntity = co2State.entity_id;
break;
}
}
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint
const statistics: string[] = [];
if (this._co2SignalEntity !== undefined) {
statistics.push(this._co2SignalEntity);
}
const prefs = this._config!.prefs;
for (const source of prefs.energy_sources) {
if (source.type === "solar") {
statistics.push(source.stat_energy_from);
continue;
}
// grid source
for (const flowFrom of source.flow_from) {
statistics.push(flowFrom.stat_energy_from);
}
for (const flowTo of source.flow_to) {
statistics.push(flowTo.stat_energy_to);
}
}
this._stats = await fetchStatistics(
this.hass!,
startDate,
undefined,
statistics
);
}
static styles = css`
:host {
--mdc-icon-size: 24px;
}
.card-content {
position: relative;
}
.lines {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 146px;
display: flex;
justify-content: center;
padding: 0 16px 16px;
box-sizing: border-box;
}
.lines svg {
width: calc(100% - 160px);
height: 100%;
max-width: 340px;
}
.row {
display: flex;
justify-content: space-between;
max-width: 500px;
margin: 0 auto;
}
.circle-container {
display: flex;
flex-direction: column;
align-items: center;
}
.circle-container.low-carbon {
margin-right: 4px;
}
.circle-container.solar {
margin-left: 4px;
height: 130px;
}
.spacer {
width: 84px;
}
.circle {
width: 80px;
height: 80px;
border-radius: 50%;
box-sizing: border-box;
border: 2px solid;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-size: 12px;
line-height: 12px;
position: relative;
text-decoration: none;
color: var(--primary-text-color);
}
ha-svg-icon {
padding-bottom: 2px;
}
ha-svg-icon.small {
--mdc-icon-size: 12px;
}
.label {
color: var(--secondary-text-color);
font-size: 12px;
}
line,
path {
stroke: var(--primary-text-color);
stroke-width: 1;
fill: none;
}
.circle svg {
position: absolute;
fill: none;
stroke-width: 4px;
width: 100%;
height: 100%;
}
.low-carbon line {
stroke: var(--energy-non-fossil-color);
}
.low-carbon .circle {
border-color: var(--energy-non-fossil-color);
}
.low-carbon ha-svg-icon {
color: var(--energy-non-fossil-color);
}
circle.low-carbon {
stroke: var(--energy-non-fossil-color);
fill: var(--energy-non-fossil-color);
}
.solar .circle {
border-color: var(--energy-solar-color);
}
circle.solar,
path.solar {
stroke: var(--energy-solar-color);
}
circle.solar {
stroke-width: 4;
fill: var(--energy-solar-color);
}
path.return,
circle.return {
stroke: var(--energy-grid-return-color);
}
circle.return {
stroke-width: 4;
fill: var(--energy-grid-return-color);
}
.return {
color: var(--energy-grid-return-color);
}
.grid .circle {
border-color: var(--energy-grid-consumption-color);
}
.consumption {
color: var(--energy-grid-consumption-color);
}
circle.grid,
path.grid {
stroke: var(--energy-grid-consumption-color);
}
circle.grid {
stroke-width: 4;
fill: var(--energy-grid-consumption-color);
}
.home .circle {
border-width: 0;
border-color: var(--primary-color);
}
.home .circle.border {
border-width: 2px;
}
.circle svg circle {
animation: rotate-in 0.6s ease-in;
transition: stroke-dashoffset 0.4s, stroke-dasharray 0.4s;
fill: none;
}
@keyframes rotate-in {
from {
stroke-dashoffset: 238.76104;
stroke-dasharray: 238.76104;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-distribution-card": HuiEnergyDistrubutionCard;
}
}

View File

@ -0,0 +1,177 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatNumber } from "../../../../common/string/format_number";
import "../../../../components/ha-card";
import "../../../../components/ha-gauge";
import type { LevelDefinition } from "../../../../components/ha-gauge";
import { GridSourceTypeEnergyPreference } from "../../../../data/energy";
import {
calculateStatisticsSumGrowth,
fetchStatistics,
Statistics,
} from "../../../../data/history";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyGridGaugeCardConfig } from "../types";
const LEVELS: LevelDefinition[] = [
{ level: -1, stroke: "var(--label-badge-red)" },
{ level: -0.2, stroke: "var(--label-badge-yellow)" },
{ level: 0, stroke: "var(--label-badge-green)" },
];
@customElement("hui-energy-grid-neutrality-gauge-card")
class HuiEnergyGridGaugeCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: EnergyGridGaugeCardConfig;
@state() private _stats?: Statistics;
public getCardSize(): number {
return 4;
}
public setConfig(config: EnergyGridGaugeCardConfig): void {
this._config = config;
}
public willUpdate(changedProps) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._getStatistics();
}
}
protected render(): TemplateResult {
if (!this._config || !this.hass) {
return html``;
}
if (!this._stats) {
return html`Loading...`;
}
const prefs = this._config!.prefs;
const gridSource = prefs.energy_sources.find(
(src) => src.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
let value: number | undefined;
if (!gridSource) {
return html``;
}
const consumedFromGrid = calculateStatisticsSumGrowth(
this._stats,
gridSource.flow_from.map((flow) => flow.stat_energy_from)
);
const returnedToGrid = calculateStatisticsSumGrowth(
this._stats,
gridSource.flow_to.map((flow) => flow.stat_energy_to)
);
if (consumedFromGrid !== null && returnedToGrid !== null) {
if (returnedToGrid > consumedFromGrid) {
value = 1 - consumedFromGrid / returnedToGrid;
} else if (returnedToGrid < consumedFromGrid) {
value = (1 - returnedToGrid / consumedFromGrid) * -1;
} else {
value = 0;
}
}
return html`
<ha-card>
${value !== undefined
? html`<ha-gauge
min="-1"
max="1"
.value=${value}
.valueText=${formatNumber(
Math.abs(returnedToGrid! - consumedFromGrid!),
this.hass.locale,
{ maximumFractionDigits: 2 }
)}
.locale=${this.hass!.locale}
.levels=${LEVELS}
label="kWh"
needle
></ha-gauge>
<div class="name">
${returnedToGrid! >= consumedFromGrid!
? "Returned to the grid"
: "Consumed from the grid"}
</div>`
: "Grid neutrality could not be calculated"}
</ha-card>
`;
}
private async _getStatistics(): Promise<void> {
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint
const statistics: string[] = [];
const prefs = this._config!.prefs;
for (const source of prefs.energy_sources) {
if (source.type === "solar") {
continue;
}
// grid source
for (const flowFrom of source.flow_from) {
statistics.push(flowFrom.stat_energy_from);
}
for (const flowTo of source.flow_to) {
statistics.push(flowTo.stat_energy_to);
}
}
this._stats = await fetchStatistics(
this.hass!,
startDate,
undefined,
statistics
);
}
static get styles(): CSSResultGroup {
return css`
ha-card {
height: 100%;
overflow: hidden;
padding: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
box-sizing: border-box;
}
ha-gauge {
width: 100%;
max-width: 250px;
}
.name {
text-align: center;
line-height: initial;
color: var(--primary-text-color);
width: 100%;
font-size: 15px;
margin-top: 8px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-grid-neutrality-gauge-card": HuiEnergyGridGaugeCard;
}
}

View File

@ -1,19 +1,18 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { round } from "../../../common/number/round"; import "../../../../components/ha-card";
import "../../../components/ha-card"; import "../../../../components/ha-gauge";
import "../../../components/ha-gauge"; import { energySourcesByType } from "../../../../data/energy";
import { energySourcesByType } from "../../../data/energy";
import { import {
calculateStatisticsSumGrowth, calculateStatisticsSumGrowth,
fetchStatistics, fetchStatistics,
Statistics, Statistics,
} from "../../../data/history"; } from "../../../../data/history";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../types"; import type { LovelaceCard } from "../../types";
import { severityMap } from "./hui-gauge-card"; import { severityMap } from "../hui-gauge-card";
import type { EnergySolarGaugeCardConfig } from "./types"; import type { EnergySolarGaugeCardConfig } from "../types";
@customElement("hui-energy-solar-consumed-gauge-card") @customElement("hui-energy-solar-consumed-gauge-card")
class HuiEnergySolarGaugeCard extends LitElement implements LovelaceCard { class HuiEnergySolarGaugeCard extends LitElement implements LovelaceCard {
@ -63,33 +62,42 @@ class HuiEnergySolarGaugeCard extends LitElement implements LovelaceCard {
let value: number | undefined; let value: number | undefined;
if (productionReturnedToGrid !== null && totalSolarProduction !== null) { if (productionReturnedToGrid !== null && totalSolarProduction) {
const cosumedSolar = totalSolarProduction - productionReturnedToGrid; const cosumedSolar = Math.max(
value = round((cosumedSolar / totalSolarProduction) * 100); 0,
totalSolarProduction - productionReturnedToGrid
);
value = (cosumedSolar / totalSolarProduction) * 100;
} }
return html` return html`
<ha-card> <ha-card>
${value ${value !== undefined
? html` <ha-gauge ? html`<ha-gauge
min="0" min="0"
max="100" max="100"
.value=${value} .value=${value}
.locale=${this.hass!.locale} .locale=${this.hass!.locale}
label="%" label="%"
style=${styleMap({ style=${styleMap({
"--gauge-color": this._computeSeverity(64), "--gauge-color": this._computeSeverity(value),
})} })}
></ha-gauge> ></ha-gauge>
<div class="name">Self consumed solar energy</div>` <div class="name">Self consumed solar energy</div>`
: html`Self consumed solar energy couldn't be calculated`} : totalSolarProduction === 0
? "You have not produced any solar energy"
: "Self consumed solar energy couldn't be calculated"}
</ha-card> </ha-card>
`; `;
} }
private _computeSeverity(numberValue: number): string { private _computeSeverity(numberValue: number): string {
if (numberValue > 50) { if (numberValue > 75) {
return severityMap.green; return severityMap.green;
} }
if (numberValue < 50) {
return severityMap.yellow;
}
return severityMap.normal; return severityMap.normal;
} }

View File

@ -8,31 +8,33 @@ import {
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import "../../../components/ha-card"; import "../../../../components/ha-card";
import { ChartData, ChartDataset, ChartOptions } from "chart.js"; import { ChartData, ChartDataset, ChartOptions } from "chart.js";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../../types";
import { LovelaceCard } from "../types"; import { LovelaceCard } from "../../types";
import { EnergySolarGraphCardConfig } from "./types"; import { EnergySolarGraphCardConfig } from "../types";
import { fetchStatistics, Statistics } from "../../../data/history"; import { fetchStatistics, Statistics } from "../../../../data/history";
import { import {
hex2rgb, hex2rgb,
lab2rgb, lab2rgb,
rgb2hex, rgb2hex,
rgb2lab, rgb2lab,
} from "../../../common/color/convert-color"; } from "../../../../common/color/convert-color";
import { labDarken } from "../../../common/color/lab"; import { labDarken } from "../../../../common/color/lab";
import { SolarSourceTypeEnergyPreference } from "../../../data/energy"; import { SolarSourceTypeEnergyPreference } from "../../../../data/energy";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { import {
ForecastSolarForecast, ForecastSolarForecast,
getForecastSolarForecasts, getForecastSolarForecasts,
} from "../../../data/forecast_solar"; } from "../../../../data/forecast_solar";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../../common/entity/compute_state_name";
import "../../../components/chart/ha-chart-base"; import "../../../../components/chart/ha-chart-base";
import "../../../components/ha-switch"; import "../../../../components/ha-switch";
import "../../../components/ha-formfield"; import "../../../../components/ha-formfield";
import {
const SOLAR_COLOR = { border: "#FF9800", background: "#ffcb80" }; formatNumber,
numberFormatToLocale,
} from "../../../../common/string/format_number";
@customElement("hui-energy-solar-graph-card") @customElement("hui-energy-solar-graph-card")
export class HuiEnergySolarGraphCard export class HuiEnergySolarGraphCard
@ -45,7 +47,9 @@ export class HuiEnergySolarGraphCard
@state() private _data?: Statistics; @state() private _data?: Statistics;
@state() private _chartData?: ChartData; @state() private _chartData: ChartData = {
datasets: [],
};
@state() private _forecasts?: Record<string, ForecastSolarForecast>; @state() private _forecasts?: Record<string, ForecastSolarForecast>;
@ -117,37 +121,38 @@ export class HuiEnergySolarGraphCard
} }
return html` return html`
<ha-card .header="${this._config.title}"> <ha-card>
${this._config.title
? html`<h1 class="card-header">${this._config.title}</h1>`
: ""}
<div <div
class="content ${classMap({ class="content ${classMap({
"has-header": !!this._config.title, "has-header": !!this._config.title,
})}" })}"
> >
<ha-formfield label="Show all forecast data" <ha-chart-base
><ha-switch .data=${this._chartData}
.checked=${this._showAllForecastData} .options=${this._chartOptions}
@change=${this._showAllForecastChanged} chart-type="bar"
></ha-switch ></ha-chart-base>
></ha-formfield>
${this._chartData
? html`<ha-chart-base
.data=${this._chartData}
.options=${this._chartOptions}
chart-type="line"
></ha-chart-base>`
: ""}
</div> </div>
</ha-card> </ha-card>
`; `;
} }
private _createOptions() { private _createOptions() {
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
const startTime = startDate.getTime();
this._chartOptions = { this._chartOptions = {
parsing: false, parsing: false,
animation: false, animation: false,
scales: { scales: {
x: { x: {
type: "time", type: "time",
suggestedMin: startTime,
suggestedMax: startTime + 24 * 60 * 60 * 1000,
adapters: { adapters: {
date: { date: {
locale: this.hass.locale, locale: this.hass.locale,
@ -166,11 +171,16 @@ export class HuiEnergySolarGraphCard
: {}, : {},
}, },
time: { time: {
tooltipFormat: "datetimeseconds", tooltipFormat: "datetime",
}, },
offset: true,
}, },
y: { y: {
type: "linear", type: "linear",
title: {
display: true,
text: "kWh",
},
ticks: { ticks: {
beginAtZero: true, beginAtZero: true,
}, },
@ -181,7 +191,10 @@ export class HuiEnergySolarGraphCard
mode: "nearest", mode: "nearest",
callbacks: { callbacks: {
label: (context) => label: (context) =>
`${context.dataset.label}: ${context.parsed.y} kWh`, `${context.dataset.label}: ${formatNumber(
context.parsed.y,
this.hass.locale
)} kWh`,
}, },
}, },
filler: { filler: {
@ -199,13 +212,16 @@ export class HuiEnergySolarGraphCard
}, },
elements: { elements: {
line: { line: {
tension: 0.4, tension: 0.3,
borderWidth: 1.5, borderWidth: 1.5,
}, },
bar: { borderWidth: 1.5, borderRadius: 4 },
point: { point: {
hitRadius: 5, hitRadius: 5,
}, },
}, },
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
}; };
} }
@ -213,6 +229,7 @@ export class HuiEnergySolarGraphCard
if (this._fetching) { if (this._fetching) {
return; return;
} }
const startDate = new Date(); const startDate = new Date();
startDate.setHours(0, 0, 0, 0); startDate.setHours(0, 0, 0, 0);
startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint
@ -252,13 +269,9 @@ export class HuiEnergySolarGraphCard
) as SolarSourceTypeEnergyPreference[]; ) as SolarSourceTypeEnergyPreference[];
const statisticsData = Object.values(this._data!); const statisticsData = Object.values(this._data!);
const datasets: ChartDataset<"line">[] = []; const datasets: ChartDataset<"bar">[] = [];
let endTime: Date; let endTime: Date;
if (statisticsData.length === 0) {
return;
}
endTime = new Date( endTime = new Date(
Math.max( Math.max(
...statisticsData.map((stats) => ...statisticsData.map((stats) =>
@ -267,28 +280,29 @@ export class HuiEnergySolarGraphCard
) )
); );
if (endTime > new Date()) { if (!endTime || endTime > new Date()) {
endTime = new Date(); endTime = new Date();
} }
const computedStyles = getComputedStyle(this);
const solarColor = computedStyles
.getPropertyValue("--energy-solar-color")
.trim();
solarSources.forEach((source, idx) => { solarSources.forEach((source, idx) => {
const data: ChartDataset<"line">[] = []; const data: ChartDataset<"bar" | "line">[] = [];
const entity = this.hass.states[source.stat_energy_from]; const entity = this.hass.states[source.stat_energy_from];
const borderColor = const borderColor =
idx > 0 idx > 0
? rgb2hex( ? rgb2hex(lab2rgb(labDarken(rgb2lab(hex2rgb(solarColor)), idx)))
lab2rgb(labDarken(rgb2lab(hex2rgb(SOLAR_COLOR.border)), idx)) : solarColor;
)
: SOLAR_COLOR.border;
data.push({ data.push({
label: `Production ${ label: `Production ${
entity ? computeStateName(entity) : source.stat_energy_from entity ? computeStateName(entity) : source.stat_energy_from
}`, }`,
fill: true, borderColor,
stepped: false,
borderColor: borderColor,
backgroundColor: borderColor + "7F", backgroundColor: borderColor + "7F",
data: [], data: [],
}); });
@ -309,7 +323,7 @@ export class HuiEnergySolarGraphCard
if (prevStart === point.start) { if (prevStart === point.start) {
continue; continue;
} }
const value = Math.round((point.sum - prevValue) * 100) / 100; const value = point.sum - prevValue;
const date = new Date(point.start); const date = new Date(point.start);
data[0].data.push({ data[0].data.push({
x: date.getTime(), x: date.getTime(),
@ -343,12 +357,15 @@ export class HuiEnergySolarGraphCard
if (forecastsData) { if (forecastsData) {
const forecast: ChartDataset<"line"> = { const forecast: ChartDataset<"line"> = {
type: "line",
label: `Forecast ${ label: `Forecast ${
entity ? computeStateName(entity) : source.stat_energy_from entity ? computeStateName(entity) : source.stat_energy_from
}`, }`,
fill: false, fill: false,
stepped: false, stepped: false,
borderColor: "#000", borderColor: computedStyles.getPropertyValue(
"--primary-text-color"
),
borderDash: [7, 5], borderDash: [7, 5],
pointRadius: 0, pointRadius: 0,
data: [], data: [],
@ -382,16 +399,14 @@ export class HuiEnergySolarGraphCard
}; };
} }
private _showAllForecastChanged(ev) {
this._showAllForecastData = ev.target.checked;
this._renderChart();
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
ha-card { ha-card {
height: 100%; height: 100%;
} }
.card-header {
padding-bottom: 0;
}
.content { .content {
padding: 16px; padding: 16px;
} }

View File

@ -0,0 +1,427 @@
// @ts-ignore
import dataTableStyles from "@material/data-table/dist/mdc.data-table.min.css";
import {
css,
CSSResultGroup,
html,
LitElement,
TemplateResult,
unsafeCSS,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import {
rgb2hex,
lab2rgb,
rgb2lab,
hex2rgb,
} from "../../../../common/color/convert-color";
import { labDarken } from "../../../../common/color/lab";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { formatNumber } from "../../../../common/string/format_number";
import "../../../../components/chart/statistics-chart";
import "../../../../components/ha-card";
import {
EnergyInfo,
energySourcesByType,
getEnergyInfo,
} from "../../../../data/energy";
import {
calculateStatisticSumGrowth,
fetchStatistics,
Statistics,
} from "../../../../data/history";
import { HomeAssistant } from "../../../../types";
import { LovelaceCard } from "../../types";
import { EnergySourcesTableCardConfig } from "../types";
@customElement("hui-energy-sources-table-card")
export class HuiEnergySourcesTableCard
extends LitElement
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergySourcesTableCardConfig;
@state() private _stats?: Statistics;
@state() private _energyInfo?: EnergyInfo;
public getCardSize(): Promise<number> | number {
return 3;
}
public setConfig(config: EnergySourcesTableCardConfig): void {
this._config = config;
}
public willUpdate() {
if (!this.hasUpdated) {
this._getEnergyInfo().then(() => this._getStatistics());
}
}
protected render(): TemplateResult {
if (!this.hass || !this._config) {
return html``;
}
if (!this._stats) {
return html`Loading...`;
}
let totalGrid = 0;
let totalSolar = 0;
let totalCost = 0;
const types = energySourcesByType(this._config.prefs);
const computedStyles = getComputedStyle(this);
const solarColor = computedStyles
.getPropertyValue("--energy-solar-color")
.trim();
const returnColor = computedStyles
.getPropertyValue("--energy-grid-return-color")
.trim();
const consumptionColor = computedStyles
.getPropertyValue("--energy-grid-consumption-color")
.trim();
const showCosts =
types.grid?.[0].flow_from.some(
(flow) =>
flow.stat_cost || flow.entity_energy_price || flow.number_energy_price
) ||
types.grid?.[0].flow_to.some(
(flow) =>
flow.stat_compensation ||
flow.entity_energy_price ||
flow.number_energy_price
);
return html` <ha-card>
${this._config.title
? html`<h1 class="card-header">${this._config.title}</h1>`
: ""}
<div class="mdc-data-table">
<div class="mdc-data-table__table-container">
<table class="mdc-data-table__table" aria-label="Dessert calories">
<thead>
<tr class="mdc-data-table__header-row">
<th class="mdc-data-table__header-cell"></th>
<th
class="mdc-data-table__header-cell"
role="columnheader"
scope="col"
>
Source
</th>
<th
class="mdc-data-table__header-cell mdc-data-table__header-cell--numeric"
role="columnheader"
scope="col"
>
Energy
</th>
${showCosts
? html` <th
class="mdc-data-table__header-cell mdc-data-table__header-cell--numeric"
role="columnheader"
scope="col"
>
Cost
</th>`
: ""}
</tr>
</thead>
<tbody class="mdc-data-table__content">
${types.solar?.map((source, idx) => {
const entity = this.hass.states[source.stat_energy_from];
const energy =
calculateStatisticSumGrowth(
this._stats![source.stat_energy_from]
) || 0;
totalSolar += energy;
const color =
idx > 0
? rgb2hex(
lab2rgb(labDarken(rgb2lab(hex2rgb(solarColor)), idx))
)
: solarColor;
return html`<tr class="mdc-data-table__row">
<td class="mdc-data-table__cell cell-bullet">
<div
class="bullet"
style=${styleMap({
borderColor: color,
backgroundColor: color + "7F",
})}
></div>
</td>
<th class="mdc-data-table__cell" scope="row">
${entity
? computeStateName(entity)
: source.stat_energy_from}
</th>
<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${formatNumber(energy, this.hass.locale)} kWh
</td>
${showCosts
? html`<td class="mdc-data-table__cell"></td>`
: ""}
</tr>`;
})}
${types.solar
? html`<tr class="mdc-data-table__row total">
<td class="mdc-data-table__cell"></td>
<th class="mdc-data-table__cell" scope="row">
Solar total
</th>
<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${formatNumber(totalSolar, this.hass.locale)} kWh
</td>
${showCosts
? html`<td class="mdc-data-table__cell"></td>`
: ""}
</tr>`
: ""}
${types.grid?.map(
(source) => html`${source.flow_from.map((flow, idx) => {
const entity = this.hass.states[flow.stat_energy_from];
const energy =
calculateStatisticSumGrowth(
this._stats![flow.stat_energy_from]
) || 0;
totalGrid += energy;
const cost_stat =
flow.stat_cost ||
this._energyInfo!.cost_sensors[flow.stat_energy_from];
const cost = cost_stat
? calculateStatisticSumGrowth(this._stats![cost_stat]) || 0
: null;
if (cost !== null) {
totalCost += cost;
}
const color =
idx > 0
? rgb2hex(
lab2rgb(
labDarken(rgb2lab(hex2rgb(consumptionColor)), idx)
)
)
: consumptionColor;
return html`<tr class="mdc-data-table__row">
<td class="mdc-data-table__cell cell-bullet">
<div
class="bullet"
style=${styleMap({
borderColor: color,
backgroundColor: color + "7F",
})}
></div>
</td>
<th class="mdc-data-table__cell" scope="row">
${entity
? computeStateName(entity)
: flow.stat_energy_from}
</th>
<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${formatNumber(energy, this.hass.locale)} kWh
</td>
${showCosts
? html` <td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${cost !== null
? formatNumber(cost, this.hass.locale, {
style: "currency",
currency: this.hass.config.currency!,
})
: ""}
</td>`
: ""}
</tr>`;
})}
${source.flow_to.map((flow, idx) => {
const entity = this.hass.states[flow.stat_energy_to];
const energy =
(calculateStatisticSumGrowth(
this._stats![flow.stat_energy_to]
) || 0) * -1;
totalGrid += energy;
const cost_stat =
flow.stat_compensation ||
this._energyInfo!.cost_sensors[flow.stat_energy_to];
const cost = cost_stat
? (calculateStatisticSumGrowth(this._stats![cost_stat]) ||
0) * -1
: null;
if (cost !== null) {
totalCost += cost;
}
const color =
idx > 0
? rgb2hex(
lab2rgb(labDarken(rgb2lab(hex2rgb(returnColor)), idx))
)
: returnColor;
return html`<tr class="mdc-data-table__row">
<td class="mdc-data-table__cell cell-bullet">
<div
class="bullet"
style=${styleMap({
borderColor: color,
backgroundColor: color + "7F",
})}
></div>
</td>
<th class="mdc-data-table__cell" scope="row">
${entity ? computeStateName(entity) : flow.stat_energy_to}
</th>
<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${formatNumber(energy, this.hass.locale)} kWh
</td>
${showCosts
? html` <td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${cost !== null
? formatNumber(cost, this.hass.locale, {
style: "currency",
currency: this.hass.config.currency!,
})
: ""}
</td>`
: ""}
</tr>`;
})}`
)}
${types.grid
? html` <tr class="mdc-data-table__row total">
<td class="mdc-data-table__cell"></td>
<th class="mdc-data-table__cell" scope="row">Grid total</th>
<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${formatNumber(totalGrid, this.hass.locale)} kWh
</td>
${showCosts
? html`<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${formatNumber(totalCost, this.hass.locale, {
style: "currency",
currency: this.hass.config.currency!,
})}
</td>`
: ""}
</tr>`
: ""}
</tbody>
</table>
</div>
</div>
</ha-card>`;
}
private async _getEnergyInfo() {
this._energyInfo = await getEnergyInfo(this.hass);
}
private async _getStatistics(): Promise<void> {
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint
const statistics: string[] = Object.values(this._energyInfo!.cost_sensors);
const prefs = this._config!.prefs;
for (const source of prefs.energy_sources) {
if (source.type === "solar") {
statistics.push(source.stat_energy_from);
} else {
// grid source
for (const flowFrom of source.flow_from) {
statistics.push(flowFrom.stat_energy_from);
if (flowFrom.stat_cost) {
statistics.push(flowFrom.stat_cost);
}
}
for (const flowTo of source.flow_to) {
statistics.push(flowTo.stat_energy_to);
if (flowTo.stat_compensation) {
statistics.push(flowTo.stat_compensation);
}
}
}
}
this._stats = await fetchStatistics(
this.hass!,
startDate,
undefined,
statistics
);
}
static get styles(): CSSResultGroup {
return css`
${unsafeCSS(dataTableStyles)}
.mdc-data-table {
width: 100%;
border: 0;
}
.mdc-data-table__header-cell,
.mdc-data-table__cell {
color: var(--primary-text-color);
border-bottom-color: var(--divider-color);
}
.mdc-data-table__row:not(.mdc-data-table__row--selected):hover {
background-color: rgba(var(--rgb-primary-text-color), 0.04);
}
.total {
--mdc-typography-body2-font-weight: 500;
}
.total .mdc-data-table__cell {
border-top: 1px solid var(--divider-color);
}
ha-card {
height: 100%;
}
.card-header {
padding-bottom: 0;
}
.content {
padding: 16px;
}
.has-header {
padding-top: 0;
}
.cell-bullet {
width: 32px;
padding-right: 0;
}
.bullet {
border-width: 1px;
border-style: solid;
border-radius: 4px;
height: 16px;
width: 32px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-sources-table-card": HuiEnergySourcesTableCard;
}
}

View File

@ -1,3 +1,4 @@
import { ChartData, ChartDataset, ChartOptions } from "chart.js";
import { import {
css, css,
CSSResultGroup, CSSResultGroup,
@ -8,47 +9,40 @@ import {
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import "../../../components/ha-card";
import { ChartData, ChartDataset, ChartOptions } from "chart.js";
import { HomeAssistant } from "../../../types";
import { LovelaceCard } from "../types";
import { EnergySummaryGraphCardConfig } from "./types";
import { fetchStatistics, Statistics } from "../../../data/history";
import { import {
hex2rgb, hex2rgb,
lab2rgb, lab2rgb,
rgb2hex, rgb2hex,
rgb2lab, rgb2lab,
} from "../../../common/color/convert-color"; } from "../../../../common/color/convert-color";
import { labDarken } from "../../../common/color/lab"; import { hexBlend } from "../../../../common/color/hex";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { labDarken } from "../../../../common/color/lab";
import "../../../components/chart/ha-chart-base"; import { computeStateName } from "../../../../common/entity/compute_state_name";
import { round } from "../../../common/number/round"; import {
formatNumber,
numberFormatToLocale,
} from "../../../../common/string/format_number";
import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import { fetchStatistics, Statistics } from "../../../../data/history";
import { HomeAssistant } from "../../../../types";
import { LovelaceCard } from "../../types";
import { EnergyUsageGraphCardConfig } from "../types";
const NEGATIVE = ["to_grid"]; @customElement("hui-energy-usage-graph-card")
const ORDER = { export class HuiEnergyUsageGraphCard
used_solar: 0,
from_grid: 100,
to_grid: 200,
};
const COLORS = {
to_grid: { border: "#56d256", background: "#87ceab" },
from_grid: { border: "#126A9A", background: "#88b5cd" },
used_solar: { border: "#FF9800", background: "#ffcb80" },
};
@customElement("hui-energy-summary-graph-card")
export class HuiEnergySummaryGraphCard
extends LitElement extends LitElement
implements LovelaceCard implements LovelaceCard
{ {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergySummaryGraphCardConfig; @state() private _config?: EnergyUsageGraphCardConfig;
@state() private _data?: Statistics; @state() private _data?: Statistics;
@state() private _chartData?: ChartData; @state() private _chartData: ChartData = {
datasets: [],
};
@state() private _chartOptions?: ChartOptions; @state() private _chartOptions?: ChartOptions;
@ -82,7 +76,7 @@ export class HuiEnergySummaryGraphCard
return 3; return 3;
} }
public setConfig(config: EnergySummaryGraphCardConfig): void { public setConfig(config: EnergyUsageGraphCardConfig): void {
this._config = config; this._config = config;
} }
@ -96,7 +90,7 @@ export class HuiEnergySummaryGraphCard
} }
const oldConfig = changedProps.get("_config") as const oldConfig = changedProps.get("_config") as
| EnergySummaryGraphCardConfig | EnergyUsageGraphCardConfig
| undefined; | undefined;
if (oldConfig !== this._config) { if (oldConfig !== this._config) {
@ -116,31 +110,38 @@ export class HuiEnergySummaryGraphCard
} }
return html` return html`
<ha-card .header="${this._config.title}"> <ha-card>
${this._config.title
? html`<h1 class="card-header">${this._config.title}</h1>`
: ""}
<div <div
class="content ${classMap({ class="content ${classMap({
"has-header": !!this._config.title, "has-header": !!this._config.title,
})}" })}"
> >
${this._chartData <ha-chart-base
? html`<ha-chart-base .data=${this._chartData}
.data=${this._chartData} .options=${this._chartOptions}
.options=${this._chartOptions} chart-type="bar"
chartType="line" ></ha-chart-base>
></ha-chart-base>`
: ""}
</div> </div>
</ha-card> </ha-card>
`; `;
} }
private _createOptions() { private _createOptions() {
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
const startTime = startDate.getTime();
this._chartOptions = { this._chartOptions = {
parsing: false, parsing: false,
animation: false, animation: false,
scales: { scales: {
x: { x: {
type: "time", type: "time",
suggestedMin: startTime,
suggestedMax: startTime + 24 * 60 * 60 * 1000,
adapters: { adapters: {
date: { date: {
locale: this.hass.locale, locale: this.hass.locale,
@ -159,15 +160,21 @@ export class HuiEnergySummaryGraphCard
: {}, : {},
}, },
time: { time: {
tooltipFormat: "datetimeseconds", tooltipFormat: "datetime",
}, },
offset: true,
}, },
y: { y: {
stacked: true, stacked: true,
type: "linear", type: "linear",
title: {
display: true,
text: "kWh",
},
ticks: { ticks: {
beginAtZero: true, beginAtZero: true,
callback: (value) => Math.abs(round(value)), callback: (value) =>
formatNumber(Math.abs(value), this.hass.locale),
}, },
}, },
}, },
@ -179,7 +186,10 @@ export class HuiEnergySummaryGraphCard
filter: (val) => val.formattedValue !== "0", filter: (val) => val.formattedValue !== "0",
callbacks: { callbacks: {
label: (context) => label: (context) =>
`${context.dataset.label}: ${Math.abs(context.parsed.y)} kWh`, `${context.dataset.label}: ${formatNumber(
Math.abs(context.parsed.y),
this.hass.locale
)} kWh`,
footer: (contexts) => { footer: (contexts) => {
let totalConsumed = 0; let totalConsumed = 0;
let totalReturned = 0; let totalReturned = 0;
@ -193,9 +203,19 @@ export class HuiEnergySummaryGraphCard
} }
} }
return [ return [
`Total consumed: ${totalConsumed.toFixed(2)} kWh`, totalConsumed
`Total returned: ${totalReturned.toFixed(2)} kWh`, ? `Total consumed: ${formatNumber(
]; totalConsumed,
this.hass.locale
)} kWh`
: "",
totalReturned
? `Total returned: ${formatNumber(
totalReturned,
this.hass.locale
)} kWh`
: "",
].filter(Boolean);
}, },
}, },
}, },
@ -213,14 +233,13 @@ export class HuiEnergySummaryGraphCard
mode: "nearest", mode: "nearest",
}, },
elements: { elements: {
line: { bar: { borderWidth: 1.5, borderRadius: 4 },
tension: 0.4,
borderWidth: 1.5,
},
point: { point: {
hitRadius: 5, hitRadius: 5,
}, },
}, },
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
}; };
} }
@ -280,7 +299,7 @@ export class HuiEnergySummaryGraphCard
} }
const statisticsData = Object.values(this._data!); const statisticsData = Object.values(this._data!);
const datasets: ChartDataset<"line">[] = []; const datasets: ChartDataset<"bar">[] = [];
let endTime: Date; let endTime: Date;
if (statisticsData.length === 0) { if (statisticsData.length === 0) {
@ -304,6 +323,23 @@ export class HuiEnergySummaryGraphCard
} = {}; } = {};
const summedData: { [key: string]: { [start: string]: number } } = {}; const summedData: { [key: string]: { [start: string]: number } } = {};
const computedStyles = getComputedStyle(this);
const colors = {
to_grid: computedStyles
.getPropertyValue("--energy-grid-return-color")
.trim(),
from_grid: computedStyles
.getPropertyValue("--energy-grid-consumption-color")
.trim(),
used_solar: computedStyles
.getPropertyValue("--energy-solar-color")
.trim(),
};
const backgroundColor = computedStyles
.getPropertyValue("--card-background-color")
.trim();
Object.entries(statistics).forEach(([key, statIds]) => { Object.entries(statistics).forEach(([key, statIds]) => {
const sum = ["solar", "to_grid"].includes(key); const sum = ["solar", "to_grid"].includes(key);
const add = key !== "solar"; const add = key !== "solar";
@ -330,7 +366,7 @@ export class HuiEnergySummaryGraphCard
totalStats[stat.start] = totalStats[stat.start] =
stat.start in totalStats ? totalStats[stat.start] + val : val; stat.start in totalStats ? totalStats[stat.start] + val : val;
} }
if (add) { if (add && !(stat.start in set)) {
set[stat.start] = val; set[stat.start] = val;
} }
prevValue = stat.sum; prevValue = stat.sum;
@ -367,12 +403,13 @@ export class HuiEnergySummaryGraphCard
const uniqueKeys = Array.from(new Set(allKeys)); const uniqueKeys = Array.from(new Set(allKeys));
Object.entries(combinedData).forEach(([type, sources]) => { Object.entries(combinedData).forEach(([type, sources]) => {
const negative = NEGATIVE.includes(type);
Object.entries(sources).forEach(([statId, source], idx) => { Object.entries(sources).forEach(([statId, source], idx) => {
const data: ChartDataset<"line">[] = []; const data: ChartDataset<"bar">[] = [];
const entity = this.hass.states[statId]; const entity = this.hass.states[statId];
const color = COLORS[type]; const borderColor =
idx > 0
? rgb2hex(lab2rgb(labDarken(rgb2lab(hex2rgb(colors[type])), idx)))
: colors[type];
data.push({ data.push({
label: label:
@ -381,30 +418,20 @@ export class HuiEnergySummaryGraphCard
: entity : entity
? computeStateName(entity) ? computeStateName(entity)
: statId, : statId,
fill: true, borderColor,
stepped: false, backgroundColor: hexBlend(borderColor, backgroundColor, 50),
order: ORDER[type] + idx, stack: "stack",
borderColor:
idx > 0
? rgb2hex(lab2rgb(labDarken(rgb2lab(hex2rgb(color.border)), idx)))
: color.border,
backgroundColor:
idx > 0
? rgb2hex(
lab2rgb(labDarken(rgb2lab(hex2rgb(color.background)), idx))
)
: color.background,
stack: negative ? "negative" : "positive",
data: [], data: [],
}); });
// Process chart data. // Process chart data.
for (const key of uniqueKeys) { for (const key of uniqueKeys) {
const value = key in source ? Math.round(source[key] * 100) / 100 : 0; const value = source[key] || 0;
const date = new Date(key); const date = new Date(key);
// @ts-expect-error
data[0].data.push({ data[0].data.push({
x: date.getTime(), x: date.getTime(),
y: value && negative ? -1 * value : value, y: value && type === "to_grid" ? -1 * value : value,
}); });
} }
@ -423,6 +450,9 @@ export class HuiEnergySummaryGraphCard
ha-card { ha-card {
height: 100%; height: 100%;
} }
.card-header {
padding-bottom: 0;
}
.content { .content {
padding: 16px; padding: 16px;
} }
@ -435,6 +465,6 @@ export class HuiEnergySummaryGraphCard
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"hui-energy-summary-graph-card": HuiEnergySummaryGraphCard; "hui-energy-usage-graph-card": HuiEnergyUsageGraphCard;
} }
} }

View File

@ -1,252 +0,0 @@
// @ts-ignore
import dataTableStyles from "@material/data-table/dist/mdc.data-table.min.css";
import {
css,
CSSResultGroup,
html,
LitElement,
TemplateResult,
unsafeCSS,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { round } from "../../../common/number/round";
import "../../../components/chart/statistics-chart";
import "../../../components/ha-card";
import {
EnergyInfo,
getEnergyInfo,
GridSourceTypeEnergyPreference,
} from "../../../data/energy";
import {
calculateStatisticSumGrowth,
fetchStatistics,
Statistics,
} from "../../../data/history";
import { HomeAssistant } from "../../../types";
import { LovelaceCard } from "../types";
import { EnergyDevicesGraphCardConfig } from "./types";
@customElement("hui-energy-costs-table-card")
export class HuiEnergyCostsTableCard
extends LitElement
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergyDevicesGraphCardConfig;
@state() private _stats?: Statistics;
@state() private _energyInfo?: EnergyInfo;
public getCardSize(): Promise<number> | number {
return 3;
}
public setConfig(config: EnergyDevicesGraphCardConfig): void {
this._config = config;
}
public willUpdate() {
if (!this.hasUpdated) {
this._getEnergyInfo().then(() => this._getStatistics());
}
}
protected render(): TemplateResult {
if (!this.hass || !this._config) {
return html``;
}
if (!this._stats) {
return html`Loading...`;
}
const source = this._config.prefs.energy_sources?.find(
(src) => src.type === "grid"
) as GridSourceTypeEnergyPreference | undefined;
if (!source) {
return html`No grid source found.`;
}
let totalEnergy = 0;
let totalCost = 0;
return html` <ha-card .header="${this._config.title}">
<div class="mdc-data-table">
<div class="mdc-data-table__table-container">
<table class="mdc-data-table__table" aria-label="Dessert calories">
<thead>
<tr class="mdc-data-table__header-row">
<th
class="mdc-data-table__header-cell"
role="columnheader"
scope="col"
>
Grid source
</th>
<th
class="mdc-data-table__header-cell mdc-data-table__header-cell--numeric"
role="columnheader"
scope="col"
>
Energy
</th>
<th
class="mdc-data-table__header-cell mdc-data-table__header-cell--numeric"
role="columnheader"
scope="col"
>
Cost
</th>
</tr>
</thead>
<tbody class="mdc-data-table__content">
${source.flow_from.map((flow) => {
const entity = this.hass.states[flow.stat_energy_from];
const energy =
calculateStatisticSumGrowth(
this._stats![flow.stat_energy_from]
) || 0;
totalEnergy += energy;
const cost_stat =
flow.stat_cost ||
this._energyInfo!.cost_sensors[flow.stat_energy_from];
const cost =
(cost_stat &&
calculateStatisticSumGrowth(this._stats![cost_stat])) ||
0;
totalCost += cost;
return html`<tr class="mdc-data-table__row">
<th class="mdc-data-table__cell" scope="row">
${entity ? computeStateName(entity) : flow.stat_energy_from}
</th>
<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${round(energy)} kWh
</td>
<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${this._config!.prefs.currency} ${cost.toFixed(2)}
</td>
</tr>`;
})}
${source.flow_to.map((flow) => {
const entity = this.hass.states[flow.stat_energy_to];
const energy =
(calculateStatisticSumGrowth(
this._stats![flow.stat_energy_to]
) || 0) * -1;
totalEnergy += energy;
const cost_stat =
flow.stat_compensation ||
this._energyInfo!.cost_sensors[flow.stat_energy_to];
const cost =
((cost_stat &&
calculateStatisticSumGrowth(this._stats![cost_stat])) ||
0) * -1;
totalCost += cost;
return html`<tr class="mdc-data-table__row">
<th class="mdc-data-table__cell" scope="row">
${entity ? computeStateName(entity) : flow.stat_energy_to}
</th>
<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${round(energy)} kWh
</td>
<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${this._config!.prefs.currency} ${cost.toFixed(2)}
</td>
</tr>`;
})}
<tr class="mdc-data-table__row total">
<th class="mdc-data-table__cell" scope="row">Total</th>
<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
${round(totalEnergy)} kWh
</td>
<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
${this._config!.prefs.currency} ${totalCost.toFixed(2)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</ha-card>`;
}
private async _getEnergyInfo() {
this._energyInfo = await getEnergyInfo(this.hass);
}
private async _getStatistics(): Promise<void> {
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint
const statistics: string[] = Object.values(this._energyInfo!.cost_sensors);
const prefs = this._config!.prefs;
for (const source of prefs.energy_sources) {
if (source.type === "solar") {
continue;
}
// grid source
for (const flowFrom of source.flow_from) {
statistics.push(flowFrom.stat_energy_from);
if (flowFrom.stat_cost) {
statistics.push(flowFrom.stat_cost);
}
}
for (const flowTo of source.flow_to) {
statistics.push(flowTo.stat_energy_to);
if (flowTo.stat_compensation) {
statistics.push(flowTo.stat_compensation);
}
}
}
this._stats = await fetchStatistics(
this.hass!,
startDate,
undefined,
statistics
);
}
static get styles(): CSSResultGroup {
return css`
${unsafeCSS(dataTableStyles)}
.mdc-data-table {
width: 100%;
border: 0;
}
.total {
background-color: var(--primary-background-color);
--mdc-typography-body2-font-weight: 500;
}
ha-card {
height: 100%;
}
.content {
padding: 16px;
}
.has-header {
padding-top: 0;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-costs-table-card": HuiEnergyCostsTableCard;
}
}

View File

@ -1,302 +0,0 @@
import { mdiCashMultiple, mdiSolarPower } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-svg-icon";
import {
energySourcesByType,
GridSourceTypeEnergyPreference,
SolarSourceTypeEnergyPreference,
} from "../../../data/energy";
import {
calculateStatisticSumGrowth,
fetchStatistics,
Statistics,
} from "../../../data/history";
import { HomeAssistant } from "../../../types";
import { LovelaceCard } from "../types";
import { EnergySummaryCardConfig } from "./types";
import "../../../components/ha-card";
const renderSumStatHelper = (
data: Statistics,
stats: string[],
unit: string
) => {
let totalGrowth = 0;
for (const stat of stats) {
if (!(stat in data)) {
return "stat missing";
}
const statGrowth = calculateStatisticSumGrowth(data[stat]);
if (statGrowth === null) {
return "incomplete data";
}
totalGrowth += statGrowth;
}
return `${totalGrowth.toFixed(2)} ${unit}`;
};
@customElement("hui-energy-summary-card")
class HuiEnergySummaryCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: EnergySummaryCardConfig;
@state() private _data?: Statistics;
private _fetching = false;
public setConfig(config: EnergySummaryCardConfig): void {
this._config = config;
}
public getCardSize(): Promise<number> | number {
return 3;
}
public willUpdate(changedProps) {
super.willUpdate(changedProps);
if (!this._fetching && !this._data) {
this._getStatistics();
}
}
protected render() {
if (!this._config || !this.hass) {
return html``;
}
const prefs = this._config!.prefs;
const types = energySourcesByType(prefs);
const hasConsumption = types.grid !== undefined;
const hasProduction = types.solar !== undefined;
const hasReturnToGrid = hasConsumption && types.grid![0].flow_to.length > 0;
const hasCost =
hasConsumption &&
types.grid![0].flow_from.some((flow) => flow.stat_cost !== null);
// total consumption = consumption_from_grid + solar_production - return_to_grid
return html`
<ha-card header="Today">
<div class="card-content">
${!hasConsumption
? ""
: html`
<div class="row">
<ha-svg-icon .path=${mdiCashMultiple}></ha-svg-icon>
<div class="label">Total Consumption</div>
<div class="data">
${!this._data
? ""
: renderSumStatHelper(
this._data,
types.grid![0].flow_from.map(
(flow) => flow.stat_energy_from
),
"kWh"
)}
</div>
</div>
`}
${!hasProduction
? ""
: html`
<div class="row">
<ha-svg-icon .path=${mdiSolarPower}></ha-svg-icon>
<div class="label">Total Production</div>
<div class="data">
${!this._data
? ""
: renderSumStatHelper(
this._data,
types.solar!.map((source) => source.stat_energy_from),
"kWh"
)}
</div>
</div>
`}
${!hasReturnToGrid
? ""
: html`
<div class="row">
<ha-svg-icon .path=${mdiCashMultiple}></ha-svg-icon>
<div class="label">Production returned to grid</div>
<div class="data">
${!this._data
? ""
: renderSumStatHelper(
this._data,
types.grid![0].flow_to.map(
(flow) => flow.stat_energy_to
),
"kWh"
)}
</div>
</div>
`}
${!hasReturnToGrid || !hasProduction
? ""
: html`
<div class="row">
<ha-svg-icon .path=${mdiCashMultiple}></ha-svg-icon>
<div class="label">Amount of produced power self used</div>
<div class="data">
${!this._data
? ""
: this._renderSolarPowerConsumptionRatio(
types.solar![0],
types.grid![0]
)}
</div>
</div>
`}
${!hasCost
? ""
: html`
<div class="row">
<ha-svg-icon .path=${mdiCashMultiple}></ha-svg-icon>
<div class="label">Total costs of today</div>
<div class="data">
${!this._data
? ""
: renderSumStatHelper(
this._data,
types
.grid![0].flow_from.map((flow) => flow.stat_cost)
.filter(Boolean) as string[],
prefs.currency
)}
</div>
</div>
`}
</div>
</ha-card>
`;
}
// This is superduper temp.
private async _getStatistics(): Promise<void> {
if (this._fetching) {
return;
}
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint
this._fetching = true;
const statistics: string[] = [];
const prefs = this._config!.prefs;
for (const source of prefs.energy_sources) {
if (source.type === "solar") {
statistics.push(source.stat_energy_from);
// Use ws command to get solar forecast
// if (source.stat_predicted_energy_from) {
// statistics.push(source.stat_predicted_energy_from);
// }
continue;
}
// grid source
for (const flowFrom of source.flow_from) {
statistics.push(flowFrom.stat_energy_from);
if (flowFrom.stat_cost) {
statistics.push(flowFrom.stat_cost);
}
}
for (const flowTo of source.flow_to) {
statistics.push(flowTo.stat_energy_to);
}
}
try {
this._data = await fetchStatistics(
this.hass!,
startDate,
undefined,
statistics
);
} finally {
this._fetching = false;
}
}
private _renderSolarPowerConsumptionRatio(
solarSource: SolarSourceTypeEnergyPreference,
gridSource: GridSourceTypeEnergyPreference
) {
let returnToGrid = 0;
for (const flowTo of gridSource.flow_to) {
if (!flowTo.stat_energy_to || !(flowTo.stat_energy_to in this._data!)) {
continue;
}
const flowReturned = calculateStatisticSumGrowth(
this._data![flowTo.stat_energy_to]
);
if (flowReturned === null) {
return "incomplete return data";
}
returnToGrid += flowReturned;
}
if (!(solarSource.stat_energy_from in this._data!)) {
return "sun stat missing";
}
const production = calculateStatisticSumGrowth(
this._data![solarSource.stat_energy_from]
);
if (production === null) {
return "incomplete solar data";
}
if (production === 0) {
return "-";
}
const consumed = Math.max(
Math.min(((production - returnToGrid) / production) * 100, 100),
0
);
return `${consumed.toFixed(1)}%`;
}
static styles = css`
.row {
display: flex;
align-items: center;
color: var(--primary-text-color);
}
ha-svg-icon {
padding: 8px;
color: var(--paper-item-icon-color);
}
div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.label {
flex: 1;
margin-left: 16px;
}
.data {
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-summary-card": HuiEnergySummaryCard;
}
}

View File

@ -1,333 +0,0 @@
import { mdiHome, mdiLeaf, mdiSolarPower, mdiTransmissionTower } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { subscribeOne } from "../../../common/util/subscribe-one";
import "../../../components/ha-svg-icon";
import { getConfigEntries } from "../../../data/config_entries";
import { energySourcesByType } from "../../../data/energy";
import { subscribeEntityRegistry } from "../../../data/entity_registry";
import {
calculateStatisticsSumGrowth,
fetchStatistics,
Statistics,
} from "../../../data/history";
import { HomeAssistant } from "../../../types";
import { LovelaceCard } from "../types";
import { EnergySummaryCardConfig } from "./types";
import "../../../components/ha-card";
import { round } from "../../../common/number/round";
@customElement("hui-energy-usage-card")
class HuiEnergyUsageCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergySummaryCardConfig;
@state() private _stats?: Statistics;
@state() private _co2SignalEntity?: string;
private _fetching = false;
public setConfig(config: EnergySummaryCardConfig): void {
this._config = config;
}
public getCardSize(): Promise<number> | number {
return 3;
}
public willUpdate(changedProps) {
super.willUpdate(changedProps);
if (!this._fetching && !this._stats) {
this._fetching = true;
Promise.all([this._getStatistics(), this._fetchCO2SignalEntity()]).then(
() => {
this._fetching = false;
}
);
}
}
protected render() {
if (!this._config) {
return html``;
}
if (!this._stats) {
return html`Loading…`;
}
const prefs = this._config!.prefs;
const types = energySourcesByType(prefs);
// The strategy only includes this card if we have a grid.
const hasConsumption = true;
const hasSolarProduction = types.solar !== undefined;
const hasReturnToGrid = hasConsumption && types.grid![0].flow_to.length > 0;
const totalGridConsumption = calculateStatisticsSumGrowth(
this._stats,
types.grid![0].flow_from.map((flow) => flow.stat_energy_from)
);
if (totalGridConsumption === null) {
return html`Total consumption couldn't be calculated`;
}
let totalSolarProduction: number | null = null;
if (hasSolarProduction) {
totalSolarProduction = calculateStatisticsSumGrowth(
this._stats,
types.solar!.map((source) => source.stat_energy_from)
);
if (totalSolarProduction === null) {
return html`Total production couldn't be calculated`;
}
}
let productionReturnedToGrid: number | null = null;
if (hasReturnToGrid) {
productionReturnedToGrid = calculateStatisticsSumGrowth(
this._stats,
types.grid![0].flow_to.map((flow) => flow.stat_energy_to)
);
if (productionReturnedToGrid === undefined) {
return html`Production returned to grid couldn't be calculated`;
}
}
// total consumption = consumption_from_grid + solar_production - return_to_grid
let co2percentage: number | undefined;
if (this._co2SignalEntity) {
const co2State = this.hass.states[this._co2SignalEntity];
if (co2State) {
co2percentage = Number(co2State.state);
if (isNaN(co2percentage)) {
co2percentage = undefined;
}
}
}
// We are calculating low carbon consumption based on what we got from the grid
// minus what we gave back because what we gave back is low carbon
const relativeGridFlow =
totalGridConsumption - (productionReturnedToGrid || 0);
let lowCarbonConsumption: number | undefined;
if (co2percentage !== undefined) {
if (relativeGridFlow > 0) {
lowCarbonConsumption = round(relativeGridFlow * (co2percentage / 100));
} else {
lowCarbonConsumption = 0;
}
}
const totalConsumption =
totalGridConsumption +
(totalSolarProduction || 0) -
(productionReturnedToGrid || 0);
const gridPctLowCarbon =
co2percentage === undefined ? 0 : co2percentage / 100;
const gridPctHighCarbon = 1 - gridPctLowCarbon;
const homePctSolar =
((totalSolarProduction || 0) - (productionReturnedToGrid || 0)) /
totalConsumption;
// When we know the ratio solar-grid, we can adjust the low/high carbon
// percentages to reflect that.
const homePctGridLowCarbon = gridPctLowCarbon * (1 - homePctSolar);
const homePctGridHighCarbon = gridPctHighCarbon * (1 - homePctSolar);
return html`
<ha-card header="Usage">
<div class="card-content">
<div class="row">
${co2percentage === undefined
? ""
: html`
<div class="circle-container">
<span class="label">Low-carbon</span>
<div class="circle low-carbon">
<ha-svg-icon .path="${mdiLeaf}"></ha-svg-icon>
${co2percentage}% / ${round(lowCarbonConsumption!)} kWh
</div>
</div>
`}
<div class="circle-container">
<span class="label">Solar</span>
<div class="circle solar">
<ha-svg-icon .path="${mdiSolarPower}"></ha-svg-icon>
${round(totalSolarProduction || 0)} kWh
</div>
</div>
</div>
<div class="row">
<div class="circle-container">
<div class="circle grid">
<ha-svg-icon .path="${mdiTransmissionTower}"></ha-svg-icon>
${round(totalGridConsumption - (productionReturnedToGrid || 0))}
kWh
<ul>
<li>
Grid high carbon: ${round(gridPctHighCarbon * 100, 1)}%
</li>
<li>Grid low carbon: ${round(gridPctLowCarbon * 100, 1)}%</li>
</ul>
</div>
<span class="label">Grid</span>
</div>
<div class="circle-container home">
<div class="circle home">
<ha-svg-icon .path="${mdiHome}"></ha-svg-icon>
${round(totalConsumption)} kWh
<ul>
<li>
Grid high carbon: ${round(homePctGridHighCarbon * 100)}%
</li>
<li>
Grid low carbon: ${round(homePctGridLowCarbon * 100)}%
</li>
<li>Solar: ${round(homePctSolar * 100)}%</li>
</ul>
</div>
<span class="label">Home</span>
</div>
</div>
</div>
</ha-card>
`;
}
private async _fetchCO2SignalEntity() {
const [configEntries, entityRegistryEntries] = await Promise.all([
getConfigEntries(this.hass),
subscribeOne(this.hass.connection, subscribeEntityRegistry),
]);
const co2ConfigEntry = configEntries.find(
(entry) => entry.domain === "co2signal"
);
if (!co2ConfigEntry) {
return;
}
for (const entry of entityRegistryEntries) {
if (entry.config_entry_id !== co2ConfigEntry.entry_id) {
continue;
}
// The integration offers 2 entities. We want the % one.
const co2State = this.hass.states[entry.entity_id];
if (!co2State || co2State.attributes.unit_of_measurement !== "%") {
continue;
}
this._co2SignalEntity = co2State.entity_id;
break;
}
}
private async _getStatistics(): Promise<void> {
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
startDate.setTime(startDate.getTime() - 1000 * 60 * 60); // subtract 1 hour to get a startpoint
const statistics: string[] = [];
const prefs = this._config!.prefs;
for (const source of prefs.energy_sources) {
if (source.type === "solar") {
statistics.push(source.stat_energy_from);
continue;
}
// grid source
for (const flowFrom of source.flow_from) {
statistics.push(flowFrom.stat_energy_from);
}
for (const flowTo of source.flow_to) {
statistics.push(flowTo.stat_energy_to);
}
}
this._stats = await fetchStatistics(
this.hass!,
startDate,
undefined,
statistics
);
}
static styles = css`
:host {
--mdc-icon-size: 26px;
}
.row {
display: flex;
margin-bottom: 30px;
}
.row:last-child {
margin-bottom: 0;
}
.circle-container {
display: flex;
flex-direction: column;
align-items: center;
margin-right: 40px;
}
.circle {
width: 80px;
height: 80px;
border-radius: 50%;
border: 2px solid;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-size: 12px;
}
.label {
color: var(--secondary-text-color);
font-size: 12px;
}
.circle-container:last-child {
margin-right: 0;
}
.circle ul {
display: none;
}
.low-carbon {
border-color: #0da035;
}
.low-carbon ha-svg-icon {
color: #0da035;
}
.solar {
border-color: #ff9800;
}
.grid {
border-color: #134763;
}
.circle-container.home {
margin-left: 120px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-usage-card": HuiEnergyUsageCard;
}
}

View File

@ -135,6 +135,8 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
style=${styleMap({ style=${styleMap({
"--gauge-color": this._computeSeverity(entityState), "--gauge-color": this._computeSeverity(entityState),
})} })}
.needle=${this._config!.needle}
.levels=${this._config!.needle ? this._severityLevels() : undefined}
></ha-gauge> ></ha-gauge>
<div class="name"> <div class="name">
${this._config.name || computeStateName(stateObj)} ${this._config.name || computeStateName(stateObj)}
@ -200,6 +202,20 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
return severityMap.normal; return severityMap.normal;
} }
private _severityLevels() {
const sections = this._config!.severity;
if (!sections) {
return [{ level: 0, stroke: severityMap.normal }];
}
const sectionsArray = Object.keys(sections);
return sectionsArray.map((severity) => ({
level: sections[severity],
stroke: severityMap[severity],
}));
}
private _handleClick(): void { private _handleClick(): void {
fireEvent(this, "hass-more-info", { entityId: this._config!.entity }); fireEvent(this, "hass-more-info", { entityId: this._config!.entity });
} }

View File

@ -133,6 +133,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
this._config, this._config,
this._configEntities this._configEntities
)} )}
.zoom=${this._config.default_zoom ?? 14}
.paths=${this._getHistoryPaths(this._config, this._history)} .paths=${this._getHistoryPaths(this._config, this._history)}
.darkMode=${this._config.dark_mode} .darkMode=${this._config.dark_mode}
></ha-map> ></ha-map>

View File

@ -19,6 +19,15 @@ import { fetchStatistics, Statistics } from "../../../data/history";
@customElement("hui-statistics-graph-card") @customElement("hui-statistics-graph-card")
export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard { export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
public static async getConfigElement() {
await import("../editor/config-elements/hui-statistics-graph-card-editor");
return document.createElement("hui-statistics-graph-card-editor");
}
public static getStubConfig(): StatisticsGraphCardConfig {
return { type: "statistics-graph", entities: [] };
}
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@state() private _statistics?: Statistics; @state() private _statistics?: Statistics;
@ -105,7 +114,10 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
| StatisticsGraphCardConfig | StatisticsGraphCardConfig
| undefined; | undefined;
if (oldConfig?.entities !== this._config.entities) { if (
oldConfig?.entities !== this._config.entities ||
oldConfig?.days_to_show !== this._config.days_to_show
) {
this._getStatistics(); this._getStatistics();
// statistics are created every hour // statistics are created every hour
clearInterval(this._interval); clearInterval(this._interval);
@ -146,7 +158,10 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
return; return;
} }
const startDate = new Date(); const startDate = new Date();
startDate.setHours(-24 * (this._config!.days_to_show || 30)); startDate.setTime(
startDate.getTime() -
1000 * 60 * 60 * (24 * (this._config!.days_to_show || 30) + 1)
);
this._fetching = true; this._fetching = true;
try { try {
this._statistics = await fetchStatistics( this._statistics = await fetchStatistics(

View File

@ -92,31 +92,54 @@ export interface ButtonCardConfig extends LovelaceCardConfig {
export interface EnergySummaryCardConfig extends LovelaceCardConfig { export interface EnergySummaryCardConfig extends LovelaceCardConfig {
type: "energy-summary"; type: "energy-summary";
title?: string;
prefs: EnergyPreferences; prefs: EnergyPreferences;
} }
export interface EnergySummaryGraphCardConfig extends LovelaceCardConfig { export interface EnergyDistributionCardConfig extends LovelaceCardConfig {
type: "energy-distribution";
title?: string;
prefs: EnergyPreferences;
}
export interface EnergyUsageGraphCardConfig extends LovelaceCardConfig {
type: "energy-summary-graph"; type: "energy-summary-graph";
title?: string;
prefs: EnergyPreferences; prefs: EnergyPreferences;
} }
export interface EnergySolarGraphCardConfig extends LovelaceCardConfig { export interface EnergySolarGraphCardConfig extends LovelaceCardConfig {
type: "energy-solar-graph"; type: "energy-solar-graph";
title?: string;
prefs: EnergyPreferences; prefs: EnergyPreferences;
} }
export interface EnergyDevicesGraphCardConfig extends LovelaceCardConfig { export interface EnergyDevicesGraphCardConfig extends LovelaceCardConfig {
type: "energy-devices-graph"; type: "energy-devices-graph";
title?: string;
prefs: EnergyPreferences;
}
export interface EnergySourcesTableCardConfig extends LovelaceCardConfig {
type: "energy-sources-table";
title?: string;
prefs: EnergyPreferences; prefs: EnergyPreferences;
} }
export interface EnergySolarGaugeCardConfig extends LovelaceCardConfig { export interface EnergySolarGaugeCardConfig extends LovelaceCardConfig {
type: "energy-solar-consumed-gauge"; type: "energy-solar-consumed-gauge";
title?: string;
prefs: EnergyPreferences;
}
export interface EnergyGridGaugeCardConfig extends LovelaceCardConfig {
type: "energy-grid-result-gauge";
title?: string;
prefs: EnergyPreferences; prefs: EnergyPreferences;
} }
export interface EnergyCarbonGaugeCardConfig extends LovelaceCardConfig { export interface EnergyCarbonGaugeCardConfig extends LovelaceCardConfig {
type: "energy-carbon-consumed-gauge"; type: "energy-carbon-consumed-gauge";
title?: string;
prefs: EnergyPreferences; prefs: EnergyPreferences;
} }
@ -147,6 +170,7 @@ export interface GaugeCardConfig extends LovelaceCardConfig {
max?: number; max?: number;
severity?: SeverityConfig; severity?: SeverityConfig;
theme?: string; theme?: string;
needle?: boolean;
} }
export interface ConfigEntity extends EntityConfig { export interface ConfigEntity extends EntityConfig {

View File

@ -35,18 +35,22 @@ const LAZY_LOAD_TYPES = {
"alarm-panel": () => import("../cards/hui-alarm-panel-card"), "alarm-panel": () => import("../cards/hui-alarm-panel-card"),
error: () => import("../cards/hui-error-card"), error: () => import("../cards/hui-error-card"),
"empty-state": () => import("../cards/hui-empty-state-card"), "empty-state": () => import("../cards/hui-empty-state-card"),
"energy-summary": () => import("../cards/hui-energy-summary-card"), "energy-usage-graph": () =>
"energy-summary-graph": () => import("../cards/energy/hui-energy-usage-graph-card"),
import("../cards/hui-energy-summary-graph-card"), "energy-solar-graph": () =>
"energy-solar-graph": () => import("../cards/hui-energy-solar-graph-card"), import("../cards/energy/hui-energy-solar-graph-card"),
"energy-devices-graph": () => "energy-devices-graph": () =>
import("../cards/hui-energy-devices-graph-card"), import("../cards/energy/hui-energy-devices-graph-card"),
"energy-costs-table": () => import("../cards/hui-energy-costs-table-card"), "energy-sources-table": () =>
"energy-usage": () => import("../cards/hui-energy-usage-card"), import("../cards/energy/hui-energy-sources-table-card"),
"energy-distribution": () =>
import("../cards/energy/hui-energy-distribution-card"),
"energy-solar-consumed-gauge": () => "energy-solar-consumed-gauge": () =>
import("../cards/hui-energy-solar-consumed-gauge-card"), import("../cards/energy/hui-energy-solar-consumed-gauge-card"),
"energy-grid-neutrality-gauge": () =>
import("../cards/energy/hui-energy-grid-neutrality-gauge-card"),
"energy-carbon-consumed-gauge": () => "energy-carbon-consumed-gauge": () =>
import("../cards/hui-energy-carbon-consumed-gauge-card"), import("../cards/energy/hui-energy-carbon-consumed-gauge-card"),
grid: () => import("../cards/hui-grid-card"), grid: () => import("../cards/hui-grid-card"),
starting: () => import("../cards/hui-starting-card"), starting: () => import("../cards/hui-starting-card"),
"entity-filter": () => import("../cards/hui-entity-filter-card"), "entity-filter": () => import("../cards/hui-entity-filter-card"),

View File

@ -1,7 +1,7 @@
import "@polymer/paper-input/paper-input"; import "@polymer/paper-input/paper-input";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { assert, number, object, optional, string } from "superstruct"; import { assert, boolean, number, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTLDirection } from "../../../../common/util/compute_rtl"; import { computeRTLDirection } from "../../../../common/util/compute_rtl";
import "../../../../components/ha-formfield"; import "../../../../components/ha-formfield";
@ -23,6 +23,7 @@ const cardConfigStruct = object({
max: optional(number()), max: optional(number()),
severity: optional(object()), severity: optional(object()),
theme: optional(string()), theme: optional(string()),
needle: optional(boolean()),
}); });
const includeDomains = ["counter", "input_number", "number", "sensor"]; const includeDomains = ["counter", "input_number", "number", "sensor"];
@ -137,6 +138,17 @@ export class HuiGaugeCardEditor
.configValue=${"max"} .configValue=${"max"}
@value-changed="${this._valueChanged}" @value-changed="${this._valueChanged}"
></paper-input> ></paper-input>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.lovelace.editor.card.gauge.needle_gauge"
)}
.dir=${computeRTLDirection(this.hass)}
>
<ha-switch
.checked="${this._config!.needle !== undefined}"
@change="${this._toggleNeedle}"
></ha-switch
></ha-formfield>
<ha-formfield <ha-formfield
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.lovelace.editor.card.gauge.severity.define" "ui.panel.lovelace.editor.card.gauge.severity.define"
@ -212,6 +224,22 @@ export class HuiGaugeCardEditor
]; ];
} }
private _toggleNeedle(ev: EntitiesEditorEvent): void {
if (!this._config || !this.hass) {
return;
}
if ((ev.target as EditorTarget).checked) {
this._config = {
...this._config,
needle: true,
};
} else {
this._config = { ...this._config };
delete this._config.needle;
}
fireEvent(this, "config-changed", { config: this._config });
}
private _toggleSeverity(ev: EntitiesEditorEvent): void { private _toggleSeverity(ev: EntitiesEditorEvent): void {
if (!this._config || !this.hass) { if (!this._config || !this.hass) {
return; return;

View File

@ -38,10 +38,6 @@ export class HuiHistoryGraphCardEditor
this._configEntities = processEditorEntities(config.entities); this._configEntities = processEditorEntities(config.entities);
} }
get _entity(): string {
return this._config!.entity || "";
}
get _title(): string { get _title(): string {
return this._config!.title || ""; return this._config!.title || "";
} }

View File

@ -0,0 +1,238 @@
import "@polymer/paper-input/paper-input";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
array,
assert,
literal,
number,
object,
optional,
string,
union,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import { HomeAssistant } from "../../../../types";
import { StatisticsGraphCardConfig } from "../../cards/types";
import { LovelaceCardEditor } from "../../types";
import { entitiesConfigStruct } from "../structs/entities-struct";
import { EditorTarget } from "../types";
import { configElementStyle } from "./config-elements-style";
import "../../../../components/entity/ha-statistics-picker";
import { processConfigEntities } from "../../common/process-config-entities";
import "../../../../components/ha-formfield";
import "../../../../components/ha-checkbox";
import { StatisticType } from "../../../../data/history";
import "../../../../components/ha-radio";
import type { HaRadio } from "../../../../components/ha-radio";
const statTypeStruct = union([
literal("sum"),
literal("min"),
literal("max"),
literal("mean"),
]);
const cardConfigStruct = object({
type: string(),
entities: array(entitiesConfigStruct),
title: optional(string()),
days_to_show: optional(number()),
chart_type: optional(union([literal("bar"), literal("line")])),
stat_types: optional(union([array(statTypeStruct), statTypeStruct])),
});
@customElement("hui-statistics-graph-card-editor")
export class HuiStatisticsGraphCardEditor
extends LitElement
implements LovelaceCardEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: StatisticsGraphCardConfig;
@state() private _configEntities?: string[];
public setConfig(config: StatisticsGraphCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
this._configEntities = config.entities
? processConfigEntities(config.entities).map((cfg) => cfg.entity)
: [];
}
get _title(): string {
return this._config!.title || "";
}
get _days_to_show(): number {
return this._config!.days_to_show || 30;
}
get _chart_type(): StatisticsGraphCardConfig["chart_type"] {
return this._config!.chart_type || "line";
}
get _stat_types(): StatisticType[] {
return this._config!.stat_types
? Array.isArray(this._config!.stat_types)
? this._config!.stat_types
: [this._config!.stat_types]
: ["mean", "min", "max", "sum"];
}
protected render(): TemplateResult {
if (!this.hass || !this._config) {
return html``;
}
return html`
<div class="card-config">
<paper-input
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.title"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
.value=${this._title}
.configValue=${"title"}
@value-changed=${this._valueChanged}
></paper-input>
<paper-input
type="number"
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.days_to_show"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
.value=${this._days_to_show}
min="1"
.configValue=${"days_to_show"}
@value-changed=${this._valueChanged}
></paper-input>
<p>Show stat types:</p>
<div class="side-by-side">
<ha-formfield label="Sum">
<ha-checkbox
.checked=${this._stat_types.includes("sum")}
name="sum"
@change=${this._statTypesChanged}
></ha-checkbox>
</ha-formfield>
<ha-formfield label="Mean">
<ha-checkbox
.checked=${this._stat_types.includes("mean")}
name="mean"
@change=${this._statTypesChanged}
></ha-checkbox>
</ha-formfield>
<ha-formfield label="Min">
<ha-checkbox
.checked=${this._stat_types.includes("min")}
name="min"
@change=${this._statTypesChanged}
></ha-checkbox>
</ha-formfield>
<ha-formfield label="Max">
<ha-checkbox
.checked=${this._stat_types.includes("max")}
name="max"
@change=${this._statTypesChanged}
></ha-checkbox>
</ha-formfield>
</div>
<div class="side-by-side">
<p>Chart type:</p>
<ha-formfield label="Line">
<ha-radio
.checked=${this._chart_type === "line"}
value="line"
name="chart_type"
@change=${this._chartTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield label="Bar">
<ha-radio
.checked=${this._chart_type === "bar"}
value="bar"
name="chart_type"
@change=${this._chartTypeChanged}
></ha-radio>
</ha-formfield>
</div>
<ha-statistics-picker
.hass=${this.hass}
.pickStatisticLabel=${`Add a statistic`}
.pickedStatisticLabel=${`Statistic`}
.value=${this._configEntities}
.configValue=${"entities"}
@value-changed=${this._valueChanged}
></ha-statistics-picker>
</div>
`;
}
private _chartTypeChanged(ev: CustomEvent) {
const input = ev.currentTarget as HaRadio;
fireEvent(this, "config-changed", {
config: { ...this._config!, chart_type: input.value },
});
}
private _statTypesChanged(ev) {
const name = ev.currentTarget.name;
const checked = ev.currentTarget.checked;
if (checked) {
fireEvent(this, "config-changed", {
config: { ...this._config!, stat_types: [...this._stat_types, name] },
});
return;
}
const statTypes = [...this._stat_types];
fireEvent(this, "config-changed", {
config: {
...this._config!,
stat_types: statTypes.filter((stat) => stat !== name),
},
});
}
private _valueChanged(ev: CustomEvent): void {
if (!this._config || !this.hass) {
return;
}
const target = ev.target! as EditorTarget;
const newValue = ev.detail?.value || target.value;
if (this[`_${target.configValue}`] === newValue) {
return;
}
if (newValue === "") {
this._config = { ...this._config };
delete this._config[target.configValue!];
} else {
let value: any = newValue;
if (target.type === "number") {
value = Number(value);
}
this._config = {
...this._config,
[target.configValue!]: value,
};
}
fireEvent(this, "config-changed", { config: this._config });
}
static get styles(): CSSResultGroup {
return configElementStyle;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-statistics-graph-card-editor": HuiStatisticsGraphCardEditor;
}
}

View File

@ -33,6 +33,10 @@ export const coreCards: Card[] = [
type: "history-graph", type: "history-graph",
showElement: true, showElement: true,
}, },
{
type: "statistics-graph",
showElement: false,
},
{ {
type: "humidifier", type: "humidifier",
showElement: true, showElement: true,

View File

@ -64,6 +64,8 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
private _mqls?: MediaQueryList[]; private _mqls?: MediaQueryList[];
private _mqlListenerRef?: () => void;
public constructor() { public constructor() {
super(); super();
this.addEventListener("iron-resize", (ev: Event) => ev.stopPropagation()); this.addEventListener("iron-resize", (ev: Event) => ev.stopPropagation());
@ -77,8 +79,9 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
public disconnectedCallback() { public disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this._mqls?.forEach((mql) => { this._mqls?.forEach((mql) => {
mql.removeListener(this._updateColumns); mql.removeListener(this._mqlListenerRef!);
}); });
this._mqlListenerRef = undefined;
this._mqls = undefined; this._mqls = undefined;
} }
@ -112,7 +115,10 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
private _initMqls() { private _initMqls() {
this._mqls = [300, 600, 900, 1200].map((width) => { this._mqls = [300, 600, 900, 1200].map((width) => {
const mql = window.matchMedia(`(min-width: ${width}px)`); const mql = window.matchMedia(`(min-width: ${width}px)`);
mql.addListener(this._updateColumns.bind(this)); if (!this._mqlListenerRef) {
this._mqlListenerRef = this._updateColumns.bind(this);
}
mql.addListener(this._mqlListenerRef);
return mql; return mql;
}); });
} }

View File

@ -17,10 +17,9 @@ import type {
} from "../../../data/lovelace"; } from "../../../data/lovelace";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { HuiErrorCard } from "../cards/hui-error-card"; import { HuiErrorCard } from "../cards/hui-error-card";
import { HuiCardOptions } from "../components/hui-card-options";
import type { Lovelace, LovelaceCard } from "../types"; import type { Lovelace, LovelaceCard } from "../types";
let editCodeLoaded = false;
export class SideBarView extends LitElement implements LovelaceViewElement { export class SideBarView extends LitElement implements LovelaceViewElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -36,6 +35,24 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
@state() private _config?: LovelaceViewConfig; @state() private _config?: LovelaceViewConfig;
private _mqlListenerRef?: () => void;
private _mql?: MediaQueryList;
public connectedCallback() {
super.connectedCallback();
this._mql = window.matchMedia("(min-width: 760px)");
this._mqlListenerRef = this._createCards.bind(this);
this._mql.addListener(this._mqlListenerRef);
}
public disconnectedCallback() {
super.disconnectedCallback();
this._mql?.removeListener(this._mqlListenerRef!);
this._mqlListenerRef = undefined;
this._mql = undefined;
}
public setConfig(config: LovelaceViewConfig): void { public setConfig(config: LovelaceViewConfig): void {
this._config = config; this._config = config;
} }
@ -43,8 +60,7 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
public willUpdate(changedProperties: PropertyValues): void { public willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties); super.willUpdate(changedProperties);
if (this.lovelace?.editMode && !editCodeLoaded) { if (this.lovelace?.editMode) {
editCodeLoaded = true;
import("./default-view-editable"); import("./default-view-editable");
} }
@ -71,7 +87,8 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
${this.lovelace?.editMode && this.cards.length === 0 <div class="container"></div>
${this.lovelace?.editMode
? html` ? html`
<ha-fab <ha-fab
.label=${this.hass!.localize( .label=${this.hass!.localize(
@ -97,49 +114,53 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
private _createCards(): void { private _createCards(): void {
const mainDiv = document.createElement("div"); const mainDiv = document.createElement("div");
mainDiv.id = "main"; mainDiv.id = "main";
const sidebarDiv = document.createElement("div");
sidebarDiv.id = "sidebar"; let sidebarDiv: HTMLDivElement;
if (this._mql?.matches) {
sidebarDiv = document.createElement("div");
sidebarDiv.id = "sidebar";
} else {
sidebarDiv = mainDiv;
}
if (this.hasUpdated) { if (this.hasUpdated) {
const oldMain = this.renderRoot.querySelector("#main"); const oldMain = this.renderRoot.querySelector("#main");
const oldSidebar = this.renderRoot.querySelector("#sidebar"); const oldSidebar = this.renderRoot.querySelector("#sidebar");
const container = this.renderRoot.querySelector(".container")!;
if (oldMain) { if (oldMain) {
this.renderRoot.removeChild(oldMain); container.removeChild(oldMain);
} }
if (oldSidebar) { if (oldSidebar) {
this.renderRoot.removeChild(oldSidebar); container.removeChild(oldSidebar);
} }
this.renderRoot.appendChild(mainDiv); container.appendChild(mainDiv);
this.renderRoot.appendChild(sidebarDiv); container.appendChild(sidebarDiv);
} else { } else {
this.updateComplete.then(() => { this.updateComplete.then(() => {
this.renderRoot.appendChild(mainDiv); const container = this.renderRoot.querySelector(".container")!;
this.renderRoot.appendChild(sidebarDiv); container.appendChild(mainDiv);
container.appendChild(sidebarDiv);
}); });
} }
this.cards.forEach((card: LovelaceCard, idx) => { this.cards.forEach((card: LovelaceCard, idx) => {
const cardConfig = this._config?.cards?.[idx]; const cardConfig = this._config?.cards?.[idx];
let element: LovelaceCard | HuiCardOptions;
if (this.isStrategy || !this.lovelace?.editMode) { if (this.isStrategy || !this.lovelace?.editMode) {
card.editMode = false; card.editMode = false;
if (cardConfig?.view_layout?.position !== "sidebar") { element = card;
mainDiv.appendChild(card);
} else {
sidebarDiv.appendChild(card);
}
return;
}
const wrapper = document.createElement("hui-card-options");
wrapper.hass = this.hass;
wrapper.lovelace = this.lovelace;
wrapper.path = [this.index!, 0];
card.editMode = true;
wrapper.appendChild(card);
if (cardConfig?.view_layout?.position !== "sidebar") {
mainDiv.appendChild(card);
} else { } else {
sidebarDiv.appendChild(card); element = document.createElement("hui-card-options");
element.hass = this.hass;
element.lovelace = this.lovelace;
element.path = [this.index!, idx];
card.editMode = true;
element.appendChild(card);
}
if (cardConfig?.view_layout?.position !== "sidebar") {
mainDiv.appendChild(element);
} else {
sidebarDiv.appendChild(element);
} }
}); });
} }
@ -147,13 +168,17 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
:host { :host {
display: flex; display: block;
padding-top: 4px; padding-top: 4px;
margin-left: 4px;
margin-right: 4px;
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
}
.container {
display: flex;
justify-content: center; justify-content: center;
margin-left: 4px;
margin-right: 4px;
} }
#main { #main {
@ -166,27 +191,18 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
max-width: 380px; max-width: 380px;
} }
:host > div { .container > div {
min-width: 0; min-width: 0;
box-sizing: border-box; box-sizing: border-box;
} }
:host > div > * { .container > div > * {
display: block; display: block;
margin: var(--masonry-view-card-margin, 4px 4px 8px); margin: var(--masonry-view-card-margin, 4px 4px 8px);
} }
@media (max-width: 760px) {
:host {
flex-direction: column;
}
#sidebar {
max-width: unset;
}
}
@media (max-width: 500px) { @media (max-width: 500px) {
:host > div > * { .container > div > * {
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
} }

View File

@ -82,6 +82,14 @@ documentContainer.innerHTML = `<custom-style>
--state-climate-dry-color: #efbd07; --state-climate-dry-color: #efbd07;
--state-climate-idle-color: #8a8a8a; --state-climate-idle-color: #8a8a8a;
/* energy */
--energy-grid-consumption-color: #126a9a;
--energy-grid-return-color: #673ab7;
--energy-solar-color: #ff9800;
--energy-non-fossil-color: #0f9d58;
--rgb-energy-solar-color: 255, 152, 0;
/* /*
Paper-styles color.html dependency is stripped on build. Paper-styles color.html dependency is stripped on build.
When a default paper-style color is used, it needs to be copied When a default paper-style color is used, it needs to be copied

View File

@ -31,6 +31,7 @@ export const darkStyles = {
"codemirror-property": "#C792EA", "codemirror-property": "#C792EA",
"codemirror-qualifier": "#DECB6B", "codemirror-qualifier": "#DECB6B",
"codemirror-type": "#DECB6B", "codemirror-type": "#DECB6B",
"energy-grid-return-color": "#b39bdb",
}; };
export const derivedStyles = { export const derivedStyles = {

View File

@ -390,6 +390,13 @@
"failed_create_area": "Failed to create area." "failed_create_area": "Failed to create area."
} }
}, },
"statistic-picker": {
"statistic": "Statistic",
"no_statistics": "You don't have any statistics",
"no_match": "No matching statistics found",
"missing_entity": "Why is my entity not listed?",
"learn_more": "Learn more about statistics"
},
"addon-picker": { "addon-picker": {
"addon": "Add-on", "addon": "Add-on",
"error": { "error": {
@ -992,8 +999,9 @@
"description": "Monitor your energy production and consumption", "description": "Monitor your energy production and consumption",
"currency": "", "currency": "",
"grid": { "grid": {
"title": "Configure grid", "title": "Electricity grid",
"sub": "Configure the different tarrifs for the energy you consume from the grid, and, if you return energy to the grid, the energy you return to the grid.", "sub": "Configure the amount of energy that you consume from the grid and, if you produce energy, give back to the grid. This allows Home Assistant to track your whole home energy usage.",
"learn_more": "More information on how to get started.",
"flow_dialog": { "flow_dialog": {
"from": { "from": {
"header": "Configure grid consumption", "header": "Configure grid consumption",
@ -1002,7 +1010,7 @@
"cost_para": "Select how Home Assistant should keep track of the costs of the consumed energy.", "cost_para": "Select how Home Assistant should keep track of the costs of the consumed energy.",
"no_cost": "Do not track costs", "no_cost": "Do not track costs",
"cost_stat": "Use an entity tracking the total costs", "cost_stat": "Use an entity tracking the total costs",
"cost_stat_input": "Entity keeping track of the total costs", "cost_stat_input": "Total Costs Entity",
"cost_entity": "Use an entity with current price", "cost_entity": "Use an entity with current price",
"cost_entity_input": "Entity with the current price", "cost_entity_input": "Entity with the current price",
"cost_number": "Use a static price", "cost_number": "Use a static price",
@ -1016,7 +1024,7 @@
"cost_para": "Do you get money back when you return energy to the grid?", "cost_para": "Do you get money back when you return energy to the grid?",
"no_cost": "I do not get money back", "no_cost": "I do not get money back",
"cost_stat": "Use an entity tracking the total recieved money", "cost_stat": "Use an entity tracking the total recieved money",
"cost_stat_input": "Entity keeping track of the total of received money", "cost_stat_input": "Total Compensation Entity",
"cost_entity": "Use an entity with current rate", "cost_entity": "Use an entity with current rate",
"cost_entity_input": "Entity with the current rate", "cost_entity_input": "Entity with the current rate",
"cost_number": "Use a static rate", "cost_number": "Use a static rate",
@ -1026,12 +1034,17 @@
} }
}, },
"solar": { "solar": {
"title": "Solar Panels",
"sub": "Let Home Assistant monitor your solar panels and give you insight on their performance.",
"learn_more": "More information on how to get started.",
"stat_production": "Your solar energy production", "stat_production": "Your solar energy production",
"stat_return_to_grid": "Solar energy returned to the grid", "stat_return_to_grid": "Solar energy returned to the grid",
"stat_predicted_production": "Prediction of your solar energy production" "stat_predicted_production": "Prediction of your solar energy production"
}, },
"device_consumption": { "device_consumption": {
"description": "If you measure the power consumption of individual devices, you can select the entities with the power consumption below", "title": "Individual devices",
"sub": "Tracking the energy usage of individual devices allows Home Assistant to break down your energy usage by device.",
"learn_more": "More information on how to get started.",
"add_stat": "Pick entity to track energy of", "add_stat": "Pick entity to track energy of",
"selected_stat": "Tracking energy for" "selected_stat": "Tracking energy for"
} }
@ -1084,9 +1097,11 @@
"unit_system_metric": "Metric", "unit_system_metric": "Metric",
"imperial_example": "Fahrenheit, pounds", "imperial_example": "Fahrenheit, pounds",
"metric_example": "Celsius, kilograms", "metric_example": "Celsius, kilograms",
"find_currency_value": "Find your value",
"save_button": "Save", "save_button": "Save",
"external_url": "External URL", "external_url": "External URL",
"internal_url": "Internal URL" "internal_url": "Internal URL",
"currency": "Currency"
} }
} }
} }
@ -2688,7 +2703,9 @@
"node_status": "Node Status", "node_status": "Node Status",
"node_ready": "Node Ready", "node_ready": "Node Ready",
"device_config": "Configure Device", "device_config": "Configure Device",
"reinterview_device": "Re-interview Device" "reinterview_device": "Re-interview Device",
"heal_node": "Heal Node",
"remove_failed": "Remove Failed Device"
}, },
"node_config": { "node_config": {
"header": "Z-Wave Device Configuration", "header": "Z-Wave Device Configuration",
@ -2741,6 +2758,14 @@
"exclusion_failed": "The node could not be removed. Please check the logs for more information.", "exclusion_failed": "The node could not be removed. Please check the logs for more information.",
"exclusion_finished": "Node {id} has been removed from your Z-Wave network." "exclusion_finished": "Node {id} has been removed from your Z-Wave network."
}, },
"remove_failed_node": {
"title": "Remove a Failed Z-Wave Device",
"introduction": "Remove a failed device from your Z-Wave network. Use this if you are unable to exclude a device normally because it is broken.",
"remove_device": "Remove Device",
"in_progress": "The device removal is in progress.",
"removal_finished": "Node {id} has been removed from your Z-Wave network.",
"removal_failed": "The device could not be removed from your Z-Wave network."
},
"reinterview_node": { "reinterview_node": {
"title": "Re-interview a Z-Wave Device", "title": "Re-interview a Z-Wave Device",
"introduction": "Re-interview a device on your Z-Wave network. Use this feature if your device has missing or incorrect functionality.", "introduction": "Re-interview a device on your Z-Wave network. Use this feature if your device has missing or incorrect functionality.",
@ -2763,6 +2788,17 @@
"healing_failed": "Healing failed. Additional information may be available in the logs.", "healing_failed": "Healing failed. Additional information may be available in the logs.",
"healing_cancelled": "Network healing has been cancelled." "healing_cancelled": "Network healing has been cancelled."
}, },
"heal_node": {
"title": "Heal a Z-Wave Device",
"introduction": "Tell {device} to update its routes back to the controller. This can help with communication issues if you have recently moved the device or your controller.",
"traffic_warning": "The healing process generates a large amount of traffic on the Z-Wave network. This may cause devices to respond slowly (or not at all) while the heal is in progress.",
"start_heal": "Heal Device",
"healing_failed": "{device} could not be healed.",
"healing_failed_check_logs": "Additional information may be available in the logs.",
"healing_complete": "{device} has been healed.",
"in_progress": "{device} healing is in progress.",
"network_heal_in_progress": "A Z-Wave network heal is already in progress. Please wait for it to finish before healing an individual device."
},
"logs": { "logs": {
"title": "Z-Wave JS Logs", "title": "Z-Wave JS Logs",
"log_level": "Log Level", "log_level": "Log Level",
@ -3059,6 +3095,7 @@
}, },
"gauge": { "gauge": {
"name": "Gauge", "name": "Gauge",
"needle_gauge": "Display as needle gauge?",
"severity": { "severity": {
"define": "Define Severity?", "define": "Define Severity?",
"green": "Green", "green": "Green",
@ -3086,6 +3123,10 @@
"name": "History Graph", "name": "History Graph",
"description": "The History Graph card allows you to display a graph for each of the entities listed." "description": "The History Graph card allows you to display a graph for each of the entities listed."
}, },
"statistics-graph": {
"name": "Statistics Graph",
"description": "The Statistics Graph card allows you to display a graph of the statistics for each of the entities listed."
},
"horizontal-stack": { "horizontal-stack": {
"name": "Horizontal Stack", "name": "Horizontal Stack",
"description": "The Horizontal Stack card allows you to stack together multiple cards, so they always sit next to each other in the space of one column." "description": "The Horizontal Stack card allows you to stack together multiple cards, so they always sit next to each other in the space of one column."
@ -3112,6 +3153,7 @@
"entity": "Entity", "entity": "Entity",
"hold_action": "Hold Action", "hold_action": "Hold Action",
"hours_to_show": "Hours to Show", "hours_to_show": "Hours to Show",
"days_to_show": "Days to Show",
"icon": "Icon", "icon": "Icon",
"icon_height": "Icon Height", "icon_height": "Icon Height",
"image": "Image Path", "image": "Image Path",
@ -3685,8 +3727,6 @@
}, },
"energy": { "energy": {
"setup": { "setup": {
"header": "Setup your energy dashboard",
"slogan": "The world is heating up. Together we can fix that.",
"next": "Next", "next": "Next",
"back": "Back", "back": "Back",
"done": "Show me my energy dashboard!" "done": "Show me my energy dashboard!"

View File

@ -60,6 +60,7 @@ const hassAttributeUtil = {
"power", "power",
"power_factor", "power_factor",
"pressure", "pressure",
"monetary",
"signal_strength", "signal_strength",
"temperature", "temperature",
"timestamp", "timestamp",

View File

@ -0,0 +1,77 @@
import { assert } from "chai";
import { calculateStatisticsSumGrowthWithPercentage } from "../../src/data/history";
describe("calculateStatisticsSumGrowthWithPercentage", () => {
it("Returns null if not enough values", async () => {
assert.strictEqual(
calculateStatisticsSumGrowthWithPercentage([], []),
null
);
});
it("Returns null if not enough values", async () => {
assert.strictEqual(
calculateStatisticsSumGrowthWithPercentage(
[
{
statistic_id: "sensor.carbon_intensity",
start: "2021-07-28T05:00:00Z",
last_reset: null,
max: 75,
mean: 50,
min: 25,
sum: null,
state: null,
},
{
statistic_id: "sensor.carbon_intensity",
start: "2021-07-28T07:00:00Z",
last_reset: null,
max: 100,
mean: 75,
min: 50,
sum: null,
state: null,
},
],
[
[
{
statistic_id: "sensor.peak_consumption",
start: "2021-07-28T04:00:00Z",
last_reset: null,
max: null,
mean: null,
min: null,
sum: 50,
state: null,
},
{
statistic_id: "sensor.peak_consumption",
start: "2021-07-28T05:00:00Z",
last_reset: null,
max: null,
mean: null,
min: null,
sum: 100,
state: null,
},
{
statistic_id: "sensor.peak_consumption",
start: "2021-07-28T07:00:00Z",
last_reset: null,
max: null,
mean: null,
min: null,
sum: 200,
state: null,
},
],
[],
]
),
100
);
});
});

1248
yarn.lock

File diff suppressed because it is too large Load Diff