Add Energy panel (#9445)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Bram Kragten 2021-07-26 18:57:59 +02:00 committed by GitHub
parent faca62b55f
commit 9dd6b3b72d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 5559 additions and 70 deletions

View File

@ -108,7 +108,7 @@ export class DialogHassioNetwork
</mwc-icon-button>
</ha-header-bar>
${this._interfaces.length > 1
? html` <mwc-tab-bar
? html`<mwc-tab-bar
.activeIndex=${this._curTabIndex}
@MDCTabBar:activated=${this._handleTabActivated}
>${this._interfaces.map(

View File

@ -44,6 +44,7 @@
"@fullcalendar/list": "5.1.0",
"@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/data-table": "=12.0.0-canary.1a8d06483.0",
"@material/mwc-button": "0.22.0-canary.cc04657a.0",
"@material/mwc-checkbox": "0.22.0-canary.cc04657a.0",
"@material/mwc-circular-progress": "0.22.0-canary.cc04657a.0",

View File

@ -59,7 +59,7 @@ export const FIXED_DEVICE_CLASS_ICONS = {
current: "hass:current-ac",
carbon_dioxide: "mdi:molecule-co2",
carbon_monoxide: "mdi:molecule-co",
energy: "hass:flash",
energy: "hass:lightning-bolt",
humidity: "hass:water-percent",
illuminance: "hass:brightness-5",
temperature: "hass:thermometer",

View File

@ -4,7 +4,7 @@ import { DEFAULT_DOMAIN_ICON } from "../const";
import { computeDomain } from "./compute_domain";
import { domainIcon } from "./domain_icon";
export const stateIcon = (state: HassEntity) => {
export const stateIcon = (state?: HassEntity) => {
if (!state) {
return DEFAULT_DOMAIN_ICON;
}

View File

@ -0,0 +1,2 @@
export const round = (value: number, precision = 2): number =>
Math.round(value * 10 ** precision) / 10 ** precision;

View File

@ -23,11 +23,11 @@ export default class HaChartBase extends LitElement {
@property({ attribute: "chart-type", reflect: true })
public chartType: ChartType = "line";
@property({ attribute: false })
public data: ChartData = { datasets: [] };
@property({ attribute: false }) public data: ChartData = { datasets: [] };
@property({ attribute: false })
public options?: ChartOptions;
@property({ attribute: false }) public options?: ChartOptions;
@property({ attribute: false }) public plugins?: any[];
@state() private _tooltip?: Tooltip;
@ -50,11 +50,14 @@ export default class HaChartBase extends LitElement {
if (!this.hasUpdated || !this.chart) {
return;
}
if (changedProps.has("plugins")) {
this.chart.destroy();
this._setupChart();
return;
}
if (changedProps.has("type")) {
this.chart.config.type = this.chartType;
}
if (changedProps.has("data")) {
this.chart.data = this.data;
}
@ -67,7 +70,7 @@ export default class HaChartBase extends LitElement {
protected render() {
return html`
${this.options?.plugins?.legend?.display === true
? html` <div class="chartLegend">
? html`<div class="chartLegend">
<ul>
${this.data.datasets.map(
(dataset, index) => html`<li
@ -133,6 +136,14 @@ export default class HaChartBase extends LitElement {
)}
</ul>
</div>
${this._tooltip.footer
? // footer has white-space: pre;
// prettier-ignore
html`<div class="footer">${Array.isArray(this._tooltip.footer)
? this._tooltip.footer.join("\n")
: this._tooltip.footer}
</div>`
: ""}
</div>`
: ""}
</div>
@ -148,14 +159,7 @@ export default class HaChartBase extends LitElement {
type: this.chartType,
data: this.data,
options: this._createOptions(),
plugins: [
{
id: "afterRenderHook",
afterRender: (chart) => {
this._height = `${chart.height}px`;
},
},
],
plugins: this._createPlugins(),
});
}
@ -177,6 +181,22 @@ export default class HaChartBase extends LitElement {
};
}
private _createPlugins() {
return [
...(this.plugins || []),
{
id: "afterRenderHook",
afterRender: (chart) => {
this._height = `${chart.height}px`;
},
legend: {
...this.options?.plugins?.legend,
display: false,
},
},
];
}
private _legendClick(ev) {
if (!this.chart) {
return;
@ -302,6 +322,10 @@ export default class HaChartBase extends LitElement {
text-align: center;
font-weight: 500;
}
.chartTooltip .footer {
font-weight: 500;
white-space: pre;
}
.chartTooltip .beforeBody {
text-align: center;
font-weight: 300;

View File

@ -119,11 +119,11 @@ class StateHistoryChartLine extends LitElement {
private _generateData() {
let colorIndex = 0;
const computedStyles = getComputedStyle(this);
const deviceStates = this.data;
const entityStates = this.data;
const datasets: ChartDataset<"line">[] = [];
let endTime: Date;
if (deviceStates.length === 0) {
if (entityStates.length === 0) {
return;
}
@ -132,7 +132,7 @@ class StateHistoryChartLine extends LitElement {
// Get the highest date from the last date of each device
new Date(
Math.max(
...deviceStates.map((devSts) =>
...entityStates.map((devSts) =>
new Date(
devSts.states[devSts.states.length - 1].last_changed
).getTime()
@ -144,7 +144,7 @@ class StateHistoryChartLine extends LitElement {
}
const names = this.names || {};
deviceStates.forEach((states) => {
entityStates.forEach((states) => {
const domain = states.domain;
const name = names[states.entity_id] || states.name;
// array containing [value1, value2, etc]

View File

@ -12,7 +12,7 @@ import type { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker";
class HaEntitiesPickerLight extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public value?: string[];
@property({ type: Array }) public value?: string[];
/**
* Show entities from specific domains.
@ -30,6 +30,22 @@ class HaEntitiesPickerLight extends LitElement {
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* Show only entities with these unit of measuments.
* @type {Array}
* @attr include-unit-of-measurement
*/
@property({ type: Array, attribute: "include-unit-of-measurement" })
public includeUnitOfMeasurement?: string[];
@property({ attribute: "picked-entity-label" })
public pickedEntityLabel?: string;
@ -51,6 +67,8 @@ class HaEntitiesPickerLight extends LitElement {
.hass=${this.hass}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.includeUnitOfMeasurement=${this.includeUnitOfMeasurement}
.entityFilter=${this._entityFilter}
.value=${entityId}
.label=${this.pickedEntityLabel}
@ -64,6 +82,8 @@ class HaEntitiesPickerLight extends LitElement {
.hass=${this.hass}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.includeUnitOfMeasurement=${this.includeUnitOfMeasurement}
.entityFilter=${this._entityFilter}
.label=${this.pickEntityLabel}
@value-changed=${this._addEntity}
@ -81,11 +101,11 @@ class HaEntitiesPickerLight extends LitElement {
}
private async _updateEntities(entities) {
this.value = entities;
fireEvent(this, "value-changed", {
value: entities,
});
this.value = entities;
}
private _entityChanged(event: PolymerChangedEvent<string>) {
@ -98,15 +118,14 @@ class HaEntitiesPickerLight extends LitElement {
) {
return;
}
if (newValue === "") {
this._updateEntities(
this._currentEntities.filter((ent) => ent !== curValue)
);
} else {
this._updateEntities(
this._currentEntities.map((ent) => (ent === curValue ? newValue : ent))
);
const currentEntities = this._currentEntities;
if (!newValue || currentEntities.includes(newValue)) {
this._updateEntities(currentEntities.filter((ent) => ent !== curValue));
return;
}
this._updateEntities(
currentEntities.map((ent) => (ent === curValue ? newValue : ent))
);
}
private async _addEntity(event: PolymerChangedEvent<string>) {

View File

@ -42,6 +42,8 @@ const rowRenderer: ComboBoxLitRenderer<HassEntity> = (item) => html`<style>
@customElement("ha-entity-picker")
export class HaEntityPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled?: boolean;
@ -49,8 +51,6 @@ export class HaEntityPicker extends LitElement {
@property({ type: Boolean, attribute: "allow-custom-entity" })
public allowCustomEntity;
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@ -79,6 +79,14 @@ export class HaEntityPicker extends LitElement {
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* Show only entities with these unit of measuments.
* @type {Array}
* @attr include-unit-of-measurement
*/
@property({ type: Array, attribute: "include-unit-of-measurement" })
public includeUnitOfMeasurement?: string[];
@property() public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ type: Boolean }) public hideClearIcon = false;
@ -110,7 +118,8 @@ export class HaEntityPicker extends LitElement {
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
entityFilter: this["entityFilter"],
includeDeviceClasses: this["includeDeviceClasses"]
includeDeviceClasses: this["includeDeviceClasses"],
includeUnitOfMeasurement: this["includeUnitOfMeasurement"]
) => {
let states: HassEntity[] = [];
@ -143,6 +152,18 @@ export class HaEntityPicker extends LitElement {
);
}
if (includeUnitOfMeasurement) {
states = states.filter(
(stateObj) =>
// We always want to include the entity of the current value
stateObj.entity_id === this.value ||
(stateObj.attributes.unit_of_measurement &&
includeUnitOfMeasurement.includes(
stateObj.attributes.unit_of_measurement
))
);
}
if (entityFilter) {
states = states.filter(
(stateObj) =>
@ -184,7 +205,7 @@ export class HaEntityPicker extends LitElement {
return !(!changedProps.has("_opened") && this._opened);
}
protected updated(changedProps: PropertyValues) {
public willUpdate(changedProps: PropertyValues) {
if (!this._initedStates || (changedProps.has("_opened") && this._opened)) {
this._states = this._getStates(
this._opened,
@ -192,23 +213,24 @@ export class HaEntityPicker extends LitElement {
this.includeDomains,
this.excludeDomains,
this.entityFilter,
this.includeDeviceClasses
this.includeDeviceClasses,
this.includeUnitOfMeasurement
);
(this.comboBox as any).filteredItems = this._states;
if (this._initedStates) {
(this.comboBox as any).filteredItems = this._states;
}
this._initedStates = true;
}
}
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
return html`
<vaadin-combo-box-light
item-value-path="entity_id"
item-label-path="entity_id"
.value=${this._value}
.allowCustomValue=${this.allowCustomEntity}
.filteredItems=${this._states}
${comboBoxRenderer(rowRenderer)}
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged}

View File

@ -0,0 +1,255 @@
import "@material/mwc-icon-button/mwc-icon-button";
import { mdiCheck } from "@mdi/js";
import "@polymer/paper-input/paper-input";
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-item/paper-item-body";
import "@vaadin/vaadin-combo-box/theme/material/vaadin-combo-box-light";
import { HassEntity } from "home-assistant-js-websocket";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { ComboBoxLitRenderer } from "lit-vaadin-helpers";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeStateName } from "../../common/entity/compute_state_name";
import { compare } from "../../common/string/compare";
import { getStatisticIds, StatisticsMetaData } from "../../data/history";
import { PolymerChangedEvent } from "../../polymer-types";
import { HomeAssistant } from "../../types";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-svg-icon";
import "./state-badge";
const rowRenderer: ComboBoxLitRenderer<{
id: string;
name: string;
state?: HassEntity;
}> = (item) => html`<style>
paper-icon-item {
padding: 0;
margin: -10px;
}
#content {
display: flex;
align-items: center;
}
:host([selected]) paper-icon-item {
margin-left: 0;
}
ha-svg-icon {
padding-left: 2px;
margin-right: -2px;
color: var(--secondary-text-color);
}
:host(:not([selected])) ha-svg-icon {
display: none;
}
</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")
export class HaStatisticPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property({ attribute: "statistic-types" })
public statisticTypes?: "mean" | "sum";
@property({ type: Array }) public statisticIds?: StatisticsMetaData[];
@property({ type: Boolean }) public disabled?: boolean;
/**
* Show only statistics with these unit of measuments.
* @type {Array}
* @attr include-unit-of-measurement
*/
@property({ type: Array, attribute: "include-unit-of-measurement" })
public includeUnitOfMeasurement?: string[];
/**
* Show only statistics on entities.
* @type {Boolean}
* @attr entities-only
*/
@property({ type: Boolean, attribute: "entities-only" })
public entitiesOnly = false;
@state() private _opened?: boolean;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _init = false;
private _getStatistics = memoizeOne(
(
statisticIds: StatisticsMetaData[],
includeUnitOfMeasurement?: string[],
entitiesOnly?: boolean
): Array<{ id: string; name: string; state?: HassEntity }> => {
if (!statisticIds.length) {
return [
{
id: "",
name: this.hass.localize(
"ui.components.statistics-picker.no_statistics"
),
},
];
}
if (includeUnitOfMeasurement) {
statisticIds = statisticIds.filter((meta) =>
includeUnitOfMeasurement.includes(meta.unit_of_measurement)
);
}
const output: Array<{
id: string;
name: string;
state?: HassEntity;
}> = [];
statisticIds.forEach((meta) => {
const entityState = this.hass.states[meta.statistic_id];
if (!entityState) {
if (!entitiesOnly) {
output.push({ id: meta.statistic_id, name: meta.statistic_id });
}
return;
}
output.push({
id: meta.statistic_id,
name: computeStateName(entityState),
state: entityState,
});
});
if (output.length === 1) {
return output;
}
return output.sort((a, b) => compare(a.name || "", b.name || ""));
}
);
public open() {
this.comboBox?.open();
}
public focus() {
this.comboBox?.focus();
}
public willUpdate(changedProps: PropertyValues) {
if (
(!this.hasUpdated && !this.statisticIds) ||
changedProps.has("statisticTypes")
) {
this._getStatisticIds();
}
if (
(!this._init && this.statisticIds) ||
(changedProps.has("_opened") && this._opened)
) {
this._init = true;
if (this.hasUpdated) {
(this.comboBox as any).items = this._getStatistics(
this.statisticIds!,
this.includeUnitOfMeasurement,
this.entitiesOnly
);
} else {
this.updateComplete.then(() => {
(this.comboBox as any).items = this._getStatistics(
this.statisticIds!,
this.includeUnitOfMeasurement,
this.entitiesOnly
);
});
}
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
.hass=${this.hass}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.statistic-picker.statistic")
: this.label}
.value=${this._value}
.renderer=${rowRenderer}
.disabled=${this.disabled}
item-value-path="id"
item-id-path="id"
item-label-path="name"
@opened-changed=${this._openedChanged}
@value-changed=${this._statisticChanged}
></ha-combo-box>
`;
}
private async _getStatisticIds() {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
}
private get _value() {
return this.value || "";
}
private _statisticChanged(ev: PolymerChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (newValue !== this._value) {
this._setValue(newValue);
}
}
private _openedChanged(ev: PolymerChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _setValue(value: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
static get styles(): CSSResultGroup {
return css`
paper-input > mwc-icon-button {
--mdc-icon-button-size: 24px;
padding: 2px;
color: var(--secondary-text-color);
}
[hidden] {
display: none;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-statistic-picker": HaStatisticPicker;
}
}

View File

@ -0,0 +1,110 @@
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { PolymerChangedEvent } from "../../polymer-types";
import type { HomeAssistant } from "../../types";
import "./ha-statistic-picker";
@customElement("ha-statistics-picker")
class HaStatisticsPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Array }) public value?: string[];
@property({ type: Array }) public statisticIds?: string[];
@property({ attribute: "statistic-types" })
public statisticTypes?: "mean" | "sum";
@property({ attribute: "picked-statistic-label" })
public pickedStatisticLabel?: string;
@property({ attribute: "pick-statistic-label" })
public pickStatisticLabel?: string;
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
const currentStatistics = this._currentStatistics;
return html`
${currentStatistics.map(
(statisticId) => html`
<div>
<ha-statistic-picker
.curValue=${statisticId}
.hass=${this.hass}
.value=${statisticId}
.statisticTypes=${this.statisticTypes}
.statisticIds=${this.statisticIds}
.label=${this.pickedStatisticLabel}
@value-changed=${this._statisticChanged}
></ha-statistic-picker>
</div>
`
)}
<div>
<ha-statistic-picker
.hass=${this.hass}
.statisticTypes=${this.statisticTypes}
.statisticIds=${this.statisticIds}
.label=${this.pickStatisticLabel}
@value-changed=${this._addStatistic}
></ha-statistic-picker>
</div>
`;
}
private get _currentStatistics() {
return this.value || [];
}
private async _updateStatistics(entities) {
this.value = entities;
fireEvent(this, "value-changed", {
value: entities,
});
}
private _statisticChanged(event: PolymerChangedEvent<string>) {
event.stopPropagation();
const oldValue = (event.currentTarget as any).curValue;
const newValue = event.detail.value;
if (newValue === oldValue) {
return;
}
const currentStatistics = this._currentStatistics;
if (!newValue || currentStatistics.includes(newValue)) {
this._updateStatistics(
currentStatistics.filter((ent) => ent !== oldValue)
);
return;
}
this._updateStatistics(
currentStatistics.map((ent) => (ent === oldValue ? newValue : ent))
);
}
private async _addStatistic(event: PolymerChangedEvent<string>) {
event.stopPropagation();
const toAdd = event.detail.value;
(event.currentTarget as any).value = "";
if (!toAdd) {
return;
}
const currentEntities = this._currentStatistics;
if (currentEntities.includes(toAdd)) {
return;
}
this._updateStatistics([...currentEntities, toAdd]);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-statistics-picker": HaStatisticsPicker;
}
}

View File

@ -1,12 +1,15 @@
import { Dialog } from "@material/mwc-dialog";
import { mdiClose } from "@mdi/js";
import { css, CSSResultGroup, html } from "lit";
import { css, CSSResultGroup, html, TemplateResult } from "lit";
import { customElement } from "lit/decorators";
import { computeRTLDirection } from "../common/util/compute_rtl";
import type { HomeAssistant } from "../types";
import "./ha-icon-button";
export const createCloseHeading = (hass: HomeAssistant, title: string) => html`
export const createCloseHeading = (
hass: HomeAssistant,
title: string | TemplateResult
) => html`
<span class="header_title">${title}</span>
<mwc-icon-button
aria-label=${hass.localize("ui.dialogs.generic.close")}

131
src/data/energy.ts Normal file
View File

@ -0,0 +1,131 @@
import { HomeAssistant } from "../types";
export const emptyFlowFromGridSourceEnergyPreference =
(): FlowFromGridSourceEnergyPreference => ({
stat_energy_from: "",
stat_cost: null,
entity_energy_from: null,
entity_energy_price: null,
number_energy_price: null,
});
export const emptyFlowToGridSourceEnergyPreference =
(): FlowToGridSourceEnergyPreference => ({
stat_energy_to: "",
stat_compensation: null,
entity_energy_to: null,
entity_energy_price: null,
number_energy_price: null,
});
export const emptyGridSourceEnergyPreference =
(): GridSourceTypeEnergyPreference => ({
type: "grid",
flow_from: [],
flow_to: [],
cost_adjustment_day: 0,
});
export const emptySolarEnergyPreference =
(): SolarSourceTypeEnergyPreference => ({
type: "solar",
stat_energy_from: "",
config_entry_solar_forecast: null,
});
export interface DeviceConsumptionEnergyPreference {
// This is an ever increasing value
stat_consumption: string;
}
export interface FlowFromGridSourceEnergyPreference {
// kWh meter
stat_energy_from: string;
// $ meter
stat_cost: string | null;
// Can be used to generate costs if stat_cost omitted
entity_energy_from: string | null;
entity_energy_price: string | null;
number_energy_price: number | null;
}
export interface FlowToGridSourceEnergyPreference {
// kWh meter
stat_energy_to: string;
// $ meter
stat_compensation: string | null;
// Can be used to generate costs if stat_cost omitted
entity_energy_to: string | null;
entity_energy_price: string | null;
number_energy_price: number | null;
}
export interface GridSourceTypeEnergyPreference {
type: "grid";
flow_from: FlowFromGridSourceEnergyPreference[];
flow_to: FlowToGridSourceEnergyPreference[];
cost_adjustment_day: number;
}
export interface SolarSourceTypeEnergyPreference {
type: "solar";
stat_energy_from: string;
config_entry_solar_forecast: string[] | null;
}
type EnergySource =
| SolarSourceTypeEnergyPreference
| GridSourceTypeEnergyPreference;
export interface EnergyPreferences {
currency: string;
energy_sources: EnergySource[];
device_consumption: DeviceConsumptionEnergyPreference[];
}
export interface EnergyInfo {
cost_sensors: Record<string, string>;
}
export const getEnergyInfo = (hass: HomeAssistant) =>
hass.callWS<EnergyInfo>({
type: "energy/info",
});
export const getEnergyPreferences = (hass: HomeAssistant) =>
hass.callWS<EnergyPreferences>({
type: "energy/get_prefs",
});
export const saveEnergyPreferences = (
hass: HomeAssistant,
prefs: Partial<EnergyPreferences>
) =>
hass.callWS<EnergyPreferences>({
type: "energy/save_prefs",
...prefs,
});
interface EnergySourceByType {
grid?: GridSourceTypeEnergyPreference[];
solar?: SolarSourceTypeEnergyPreference[];
}
export const energySourcesByType = (prefs: EnergyPreferences) => {
const types: EnergySourceByType = {};
for (const source of prefs.energy_sources) {
if (source.type in types) {
types[source.type]!.push(source as any);
} else {
types[source.type] = [source as any];
}
}
return types;
};

View File

@ -0,0 +1,10 @@
import { HomeAssistant } from "../types";
export interface ForecastSolarForecast {
wh_hours: Record<string, number>;
}
export const getForecastSolarForecasts = (hass: HomeAssistant) =>
hass.callWS<Record<string, ForecastSolarForecast>>({
type: "forecast_solar/forecasts",
});

View File

@ -70,6 +70,11 @@ export interface StatisticValue {
state: number | null;
}
export interface StatisticsMetaData {
unit_of_measurement: string;
statistic_id: string;
}
export const fetchRecent = (
hass: HomeAssistant,
entityId: string,
@ -276,7 +281,7 @@ export const getStatisticIds = (
hass: HomeAssistant,
statistic_type?: "mean" | "sum"
) =>
hass.callWS<string[]>({
hass.callWS<StatisticsMetaData[]>({
type: "history/list_statistic_ids",
statistic_type,
});
@ -294,6 +299,48 @@ export const fetchStatistics = (
statistic_ids,
});
export const calculateStatisticSumGrowth = (
values: StatisticValue[]
): number | null => {
if (values.length === 0) {
return null;
}
if (values.length === 1) {
return values[0].sum;
}
const endSum = values[values.length - 1].sum;
if (endSum === null) {
return null;
}
const startSum = values[0].sum;
if (startSum === null) {
return endSum;
}
return endSum - startSum;
};
export const calculateStatisticsSumGrowth = (
data: Statistics,
stats: string[]
): number | null => {
let totalGrowth = 0;
for (const stat of stats) {
if (!(stat in data)) {
return null;
}
const statGrowth = calculateStatisticSumGrowth(data[stat]);
if (statGrowth === null) {
return null;
}
totalGrowth += statGrowth;
}
return totalGrowth;
};
export const statisticsHaveType = (
stats: StatisticValue[],
type: StatisticType

View File

@ -169,9 +169,11 @@ class DataEntryFlowDialog extends LitElement {
this._params.flowConfig.deleteFlow(this.hass, this._step.flow_id);
}
if (this._step !== null && this._params.dialogClosedCallback) {
if (this._step && this._params.dialogClosedCallback) {
this._params.dialogClosedCallback({
flowFinished,
entryId:
"result" in this._step ? this._step.result?.entry_id : undefined,
});
}

View File

@ -39,6 +39,8 @@ export const showConfigFlowDialog = (
const [step] = await Promise.all([
createConfigFlow(hass, handler),
hass.loadBackendTranslation("config", handler),
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", handler),
]);
return step;
},

View File

@ -97,7 +97,10 @@ export type LoadingReason =
export interface DataEntryFlowDialogParams {
startFlowHandler?: string;
continueFlowId?: string;
dialogClosedCallback?: (params: { flowFinished: boolean }) => void;
dialogClosedCallback?: (params: {
flowFinished: boolean;
entryId?: string;
}) => void;
flowConfig: FlowConfig;
showAdvanced?: boolean;
}

View File

@ -62,7 +62,6 @@ export const showDialog = async (
LOADED[dialogTag] = dialogImport().then(() => {
const dialogEl = document.createElement(dialogTag) as HassDialog;
element.provideHass(dialogEl);
root.appendChild(dialogEl);
return dialogEl;
});
}
@ -94,6 +93,9 @@ export const showDialog = async (
}
}
const dialogElement = await LOADED[dialogTag];
// Append it again so it's the last element in the root,
// so it's guaranteed to be on top of the other elements
root.appendChild(dialogElement);
dialogElement.showDialog(dialogParams);
};

View File

@ -13,7 +13,7 @@ class HassErrorScreen extends LitElement {
@property({ type: Boolean }) public rootnav = false;
@property() public narrow?: boolean;
@property({ type: Boolean }) public narrow = false;
@property() public error?: string;

View File

@ -16,7 +16,7 @@ class HassLoadingScreen extends LitElement {
@property({ type: Boolean }) public rootnav = false;
@property() public narrow?: boolean;
@property({ type: Boolean }) public narrow = false;
protected render(): TemplateResult {
return html`

View File

@ -20,6 +20,7 @@ import {
const CACHE_URL_PATHS = ["lovelace", "developer-tools"];
const COMPONENTS = {
energy: () => import("../panels/energy/ha-panel-energy"),
calendar: () => import("../panels/calendar/ha-panel-calendar"),
config: () => import("../panels/config/ha-panel-config"),
custom: () => import("../panels/custom/ha-panel-custom"),
@ -43,7 +44,7 @@ const COMPONENTS = {
class PartialPanelResolver extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow?: boolean;
@property({ type: Boolean }) public narrow = false;
private _waitForStart = false;

View File

@ -0,0 +1,118 @@
import "@material/mwc-button/mwc-button";
import { mdiDelete, mdiDevices } from "@mdi/js";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { stateIcon } from "../../../../common/entity/state_icon";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-card";
import "../../../../components/ha-settings-row";
import {
DeviceConsumptionEnergyPreference,
EnergyPreferences,
saveEnergyPreferences,
} from "../../../../data/energy";
import {
showConfirmationDialog,
showAlertDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import { showEnergySettingsDeviceDialog } from "../dialogs/show-dialogs-energy";
import { energyCardStyles } from "./styles";
@customElement("ha-energy-device-settings")
export class EnergyDeviceSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public preferences!: EnergyPreferences;
protected render(): TemplateResult {
return html`
<ha-card>
<h1 class="card-header">
<ha-svg-icon .path=${mdiDevices}></ha-svg-icon>Monitor individual
devices
</h1>
<div class="card-content">
<p>Monitor individual devices.</p>
<h3>Devices</h3>
${this.preferences.device_consumption.map((device) => {
const entityState = this.hass.states[device.stat_consumption];
return html`
<div class="row">
<ha-icon .icon=${stateIcon(entityState)}></ha-icon>
<span class="content"
>${entityState
? computeStateName(entityState)
: device.stat_consumption}</span
>
<mwc-icon-button @click=${this._deleteDevice} .device=${device}>
<ha-svg-icon .path=${mdiDelete}></ha-svg-icon>
</mwc-icon-button>
</div>
`;
})}
<div class="row">
<ha-svg-icon .path=${mdiDevices}></ha-svg-icon>
<mwc-button @click=${this._addDevice}>Add device</mwc-button>
</div>
</div>
</ha-card>
`;
}
private _addDevice() {
showEnergySettingsDeviceDialog(this, {
saveCallback: async (device) => {
await this._savePreferences({
...this.preferences,
device_consumption:
this.preferences.device_consumption.concat(device),
});
},
});
}
private async _deleteDevice(ev) {
const deviceToDelete: DeviceConsumptionEnergyPreference =
ev.currentTarget.device;
if (
!(await showConfirmationDialog(this, {
title: "Are you sure you wan't to delete this device?",
}))
) {
return;
}
try {
await this._savePreferences({
...this.preferences,
device_consumption: this.preferences.device_consumption.filter(
(device) => device !== deviceToDelete
),
});
} catch (err) {
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
}
}
private async _savePreferences(preferences: EnergyPreferences) {
const result = await saveEnergyPreferences(this.hass, preferences);
fireEvent(this, "value-changed", { value: result });
}
static get styles(): CSSResultGroup {
return [haStyle, energyCardStyles];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-energy-device-settings": EnergyDeviceSettings;
}
}

View File

@ -0,0 +1,367 @@
import "@material/mwc-button/mwc-button";
import {
mdiDelete,
mdiHomeExportOutline,
mdiHomeImportOutline,
mdiPencil,
mdiTransmissionTower,
} from "@mdi/js";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-card";
import "../../../../components/ha-settings-row";
import {
ConfigEntry,
deleteConfigEntry,
getConfigEntries,
} from "../../../../data/config_entries";
import {
emptyGridSourceEnergyPreference,
EnergyPreferences,
energySourcesByType,
FlowFromGridSourceEnergyPreference,
FlowToGridSourceEnergyPreference,
saveEnergyPreferences,
} from "../../../../data/energy";
import { showConfigFlowDialog } from "../../../../dialogs/config-flow/show-dialog-config-flow";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import {
showEnergySettingsGridFlowFromDialog,
showEnergySettingsGridFlowToDialog,
} from "../dialogs/show-dialogs-energy";
import { energyCardStyles } from "./styles";
@customElement("ha-energy-grid-settings")
export class EnergyGridSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public preferences!: EnergyPreferences;
@state() private _configEntries?: ConfigEntry[];
protected firstUpdated() {
this._fetchCO2SignalConfigEntries();
}
protected render(): TemplateResult {
const types = energySourcesByType(this.preferences);
const gridSource = types.grid
? types.grid[0]
: emptyGridSourceEnergyPreference();
return html`
<ha-card>
<h1 class="card-header">
<ha-svg-icon .path=${mdiTransmissionTower}></ha-svg-icon
>${this.hass.localize("ui.panel.config.energy.grid.title")}
</h1>
<div class="card-content">
<p>${this.hass.localize("ui.panel.config.energy.grid.sub")}</p>
<h3>Grid consumption</h3>
${gridSource.flow_from.map((flow) => {
const entityState = this.hass.states[flow.stat_energy_from];
return html`
<div class="row" .source=${flow}>
${entityState?.attributes.icon
? html`<ha-icon
.icon=${entityState?.attributes.icon}
></ha-icon>`
: html`<ha-svg-icon
.path=${mdiHomeImportOutline}
></ha-svg-icon>`}
<span class="content"
>${entityState
? computeStateName(entityState)
: flow.stat_energy_from}</span
>
<mwc-icon-button @click=${this._editFromSource}>
<ha-svg-icon .path=${mdiPencil}></ha-svg-icon>
</mwc-icon-button>
<mwc-icon-button @click=${this._deleteFromSource}>
<ha-svg-icon .path=${mdiDelete}></ha-svg-icon>
</mwc-icon-button>
</div>
`;
})}
<div class="row border-bottom">
<ha-svg-icon .path=${mdiHomeImportOutline}></ha-svg-icon>
<mwc-button @click=${this._addFromSource}
>Add consumption</mwc-button
>
</div>
<h3>Return to grid</h3>
${gridSource.flow_to.map((flow) => {
const entityState = this.hass.states[flow.stat_energy_to];
return html`
<div class="row" .source=${flow}>
${entityState?.attributes.icon
? html`<ha-icon
.icon=${entityState.attributes.icon}
></ha-icon>`
: html`<ha-svg-icon
.path=${mdiHomeExportOutline}
></ha-svg-icon>`}
<span class="content"
>${entityState
? computeStateName(entityState)
: flow.stat_energy_to}</span
>
<mwc-icon-button @click=${this._editToSource}>
<ha-svg-icon .path=${mdiPencil}></ha-svg-icon>
</mwc-icon-button>
<mwc-icon-button @click=${this._deleteToSource}>
<ha-svg-icon .path=${mdiDelete}></ha-svg-icon>
</mwc-icon-button>
</div>
`;
})}
<div class="row border-bottom">
<ha-svg-icon .path=${mdiHomeExportOutline}></ha-svg-icon>
<mwc-button @click=${this._addToSource}>Add return</mwc-button>
</div>
<h3>Grid carbon footprint</h3>
${this._configEntries?.map(
(entry) => html`<div class="row" .entry=${entry}>
<img
referrerpolicy="no-referrer"
src="https://brands.home-assistant.io/co2signal/icon.png"
/>
<span class="content">${entry.title}</span>
<a href=${`/config/integrations#config_entry=${entry.entry_id}`}>
<mwc-icon-button>
<ha-svg-icon .path=${mdiPencil}></ha-svg-icon>
</mwc-icon-button>
</a>
<mwc-icon-button @click=${this._removeCO2Sensor}>
<ha-svg-icon .path=${mdiDelete}></ha-svg-icon>
</mwc-icon-button>
</div>`
)}
${this._configEntries?.length === 0
? html`
<div class="row border-bottom">
<img
referrerpolicy="no-referrer"
src="https://brands.home-assistant.io/co2signal/icon.png"
/>
<mwc-button @click=${this._addCO2Sensor}>
Add CO2 signal integration
</mwc-button>
</div>
`
: ""}
</div>
</ha-card>
`;
}
private async _fetchCO2SignalConfigEntries() {
this._configEntries = (await getConfigEntries(this.hass)).filter(
(entry) => entry.domain === "co2signal"
);
}
private _addCO2Sensor() {
showConfigFlowDialog(this, {
startFlowHandler: "co2signal",
dialogClosedCallback: () => {
this._fetchCO2SignalConfigEntries();
},
});
}
private async _removeCO2Sensor(ev) {
const entryId = ev.currentTarget.closest(".row").entry.entry_id;
if (
!(await showConfirmationDialog(this, {
title:
"Are you sure you wan't to delete this integration? It will remove the entities it provides",
}))
) {
return;
}
await deleteConfigEntry(this.hass, entryId);
this._fetchCO2SignalConfigEntries();
}
private _addFromSource() {
showEnergySettingsGridFlowFromDialog(this, {
currency: this.preferences.currency,
saveCallback: async (source) => {
const flowFrom = energySourcesByType(this.preferences).grid![0]
.flow_from;
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? { ...src, flow_from: [...flowFrom, source] }
: src
),
};
await this._savePreferences(preferences);
},
});
}
private _addToSource() {
showEnergySettingsGridFlowToDialog(this, {
currency: this.preferences.currency,
saveCallback: async (source) => {
const flowTo = energySourcesByType(this.preferences).grid![0].flow_to;
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid" ? { ...src, flow_to: [...flowTo, source] } : src
),
};
await this._savePreferences(preferences);
},
});
}
private _editFromSource(ev) {
const origSource: FlowFromGridSourceEnergyPreference =
ev.currentTarget.closest(".row").source;
showEnergySettingsGridFlowFromDialog(this, {
currency: this.preferences.currency,
source: { ...origSource },
saveCallback: async (source) => {
const flowFrom = energySourcesByType(this.preferences).grid![0]
.flow_from;
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? {
...src,
flow_from: flowFrom.map((flow) =>
flow === origSource ? source : flow
),
}
: src
),
};
await this._savePreferences(preferences);
},
});
}
private _editToSource(ev) {
const origSource: FlowToGridSourceEnergyPreference =
ev.currentTarget.closest(".row").source;
showEnergySettingsGridFlowToDialog(this, {
currency: this.preferences.currency,
source: { ...origSource },
saveCallback: async (source) => {
const flowTo = energySourcesByType(this.preferences).grid![0].flow_to;
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src.type === "grid"
? {
...src,
flow_to: flowTo.map((flow) =>
flow === origSource ? source : flow
),
}
: src
),
};
await this._savePreferences(preferences);
},
});
}
private async _deleteFromSource(ev) {
const sourceToDelete: FlowFromGridSourceEnergyPreference =
ev.currentTarget.closest(".row").source;
if (
!(await showConfirmationDialog(this, {
title: "Are you sure you wan't to delete this source?",
}))
) {
return;
}
const flowFrom = energySourcesByType(
this.preferences
).grid![0].flow_from.filter((flow) => flow !== sourceToDelete);
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((source) =>
source.type === "grid" ? { ...source, flow_from: flowFrom } : source
),
};
try {
await this._savePreferences(preferences);
} catch (err) {
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
}
}
private async _deleteToSource(ev) {
const sourceToDelete: FlowToGridSourceEnergyPreference =
ev.currentTarget.closest(".row").source;
if (
!(await showConfirmationDialog(this, {
title: "Are you sure you wan't to delete this source?",
}))
) {
return;
}
const flowTo = energySourcesByType(
this.preferences
).grid![0].flow_to.filter((flow) => flow !== sourceToDelete);
const preferences: EnergyPreferences = {
...this.preferences,
energy_sources: this.preferences.energy_sources.map((source) =>
source.type === "grid" ? { ...source, flow_to: flowTo } : source
),
};
try {
await this._savePreferences(preferences);
} catch (err) {
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
}
}
private async _savePreferences(preferences: EnergyPreferences) {
const result = await saveEnergyPreferences(this.hass, preferences);
fireEvent(this, "value-changed", { value: result });
}
static get styles(): CSSResultGroup {
return [haStyle, energyCardStyles];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-energy-grid-settings": EnergyGridSettings;
}
}

View File

@ -0,0 +1,149 @@
import "@material/mwc-button/mwc-button";
import { mdiDelete, mdiPencil, mdiSolarPower } from "@mdi/js";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-card";
import "../../../../components/ha-settings-row";
import {
EnergyPreferences,
energySourcesByType,
saveEnergyPreferences,
SolarSourceTypeEnergyPreference,
} from "../../../../data/energy";
import {
showConfirmationDialog,
showAlertDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import { showEnergySettingsSolarDialog } from "../dialogs/show-dialogs-energy";
import { energyCardStyles } from "./styles";
@customElement("ha-energy-solar-settings")
export class EnergySolarSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public preferences!: EnergyPreferences;
protected render(): TemplateResult {
const types = energySourcesByType(this.preferences);
const solarSources = types.solar || [];
return html`
<ha-card>
<h1 class="card-header">
<ha-svg-icon .path=${mdiSolarPower}></ha-svg-icon>Configure solar
panels
</h1>
<div class="card-content">
<p>
Let Home Assistant monitor your solar panels and give you insight on
their performace.
</p>
<h3>Solar production</h3>
${solarSources.map((source) => {
const entityState = this.hass.states[source.stat_energy_from];
return html`
<div class="row" .source=${source}>
${entityState?.attributes.icon
? html`<ha-icon
.icon=${entityState.attributes.icon}
></ha-icon>`
: html`<ha-svg-icon .path=${mdiSolarPower}></ha-svg-icon>`}
<span class="content"
>${entityState
? computeStateName(entityState)
: source.stat_energy_from}</span
>
<mwc-icon-button @click=${this._editSource}>
<ha-svg-icon .path=${mdiPencil}></ha-svg-icon>
</mwc-icon-button>
<mwc-icon-button @click=${this._deleteSource}>
<ha-svg-icon .path=${mdiDelete}></ha-svg-icon>
</mwc-icon-button>
</div>
`;
})}
<div class="row border-bottom">
<ha-svg-icon .path=${mdiSolarPower}></ha-svg-icon>
<mwc-button @click=${this._addSource}
>Add solar production</mwc-button
>
</div>
</div>
</ha-card>
`;
}
private _addSource() {
showEnergySettingsSolarDialog(this, {
saveCallback: async (source) => {
await this._savePreferences({
...this.preferences,
energy_sources: this.preferences.energy_sources.concat(source),
});
},
});
}
private _editSource(ev) {
const origSource: SolarSourceTypeEnergyPreference =
ev.currentTarget.closest(".row").source;
showEnergySettingsSolarDialog(this, {
source: { ...origSource },
saveCallback: async (newSource) => {
await this._savePreferences({
...this.preferences,
energy_sources: this.preferences.energy_sources.map((src) =>
src === origSource ? newSource : src
),
});
},
});
}
private async _deleteSource(ev) {
const sourceToDelete: SolarSourceTypeEnergyPreference =
ev.currentTarget.closest(".row").source;
if (
!(await showConfirmationDialog(this, {
title: "Are you sure you wan't to delete this source?",
}))
) {
return;
}
try {
await this._savePreferences({
...this.preferences,
energy_sources: this.preferences.energy_sources.filter(
(source) => source !== sourceToDelete
),
});
} catch (err) {
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
}
}
private async _savePreferences(preferences: EnergyPreferences) {
const result = await saveEnergyPreferences(this.hass, preferences);
fireEvent(this, "value-changed", { value: result });
}
static get styles(): CSSResultGroup {
return [haStyle, energyCardStyles];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-energy-solar-settings": EnergySolarSettings;
}
}

View File

@ -0,0 +1,37 @@
import { css } from "lit";
export const energyCardStyles = css`
ha-card {
height: 100%;
}
.card-header ha-svg-icon {
height: 32px;
width: 32px;
margin-right: 8px;
}
h3 {
margin-top: 24px;
margin-bottom: 4px;
}
.row {
display: flex;
align-items: center;
border-top: 1px solid var(--divider-color);
height: 48px;
box-sizing: border-box;
}
.row ha-svg-icon,
.row ha-icon,
.row img {
margin-right: 16px;
}
.row img {
height: 24px;
}
.row .content {
flex-grow: 1;
}
mwc-icon-button {
color: var(--secondary-text-color);
}
`;

View File

@ -0,0 +1,111 @@
import { mdiDevices } from "@mdi/js";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-dialog";
import { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import { EnergySettingsDeviceDialogParams } from "./show-dialogs-energy";
import "@material/mwc-button/mwc-button";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-radio";
import "../../../../components/ha-formfield";
import "../../../../components/entity/ha-entity-picker";
const energyUnits = ["kWh"];
@customElement("dialog-energy-device-settings")
export class DialogEnergyDeviceSettings
extends LitElement
implements HassDialog<EnergySettingsDeviceDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EnergySettingsDeviceDialogParams;
@state() private _device?: DeviceConsumptionEnergyPreference;
@state() private _error?: string;
public async showDialog(
params: EnergySettingsDeviceDialogParams
): Promise<void> {
this._params = params;
}
public closeDialog(): void {
this._params = undefined;
this._device = undefined;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
if (!this._params) {
return html``;
}
return html`
<ha-dialog
open
.heading=${html`<ha-svg-icon
.path=${mdiDevices}
style="--mdc-icon-size: 32px;"
></ha-svg-icon>
Add a device`}
@closed=${this.closeDialog}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
<p>Track your devices <a href="#">Learn more</a></p>
<ha-statistic-picker
.hass=${this.hass}
.includeUnitOfMeasurement=${energyUnits}
.label=${`Device production energy (kWh)`}
entities-only
@value-changed=${this._statisticChanged}
></ha-statistic-picker>
<mwc-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass.localize("ui.common.cancel")}
</mwc-button>
<mwc-button
@click=${this._save}
.disabled=${!this._device}
slot="primaryAction"
>
${this.hass.localize("ui.common.save")}
</mwc-button>
</ha-dialog>
`;
}
private _statisticChanged(ev: CustomEvent<{ value: string }>) {
if (!ev.detail.value) {
this._device = undefined;
return;
}
this._device = { stat_consumption: ev.detail.value };
}
private async _save() {
try {
await this._params!.saveCallback(this._device!);
this.closeDialog();
} catch (e) {
this._error = e.message;
}
}
static get styles(): CSSResultGroup {
return haStyleDialog;
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-energy-device-settings": DialogEnergyDeviceSettings;
}
}

View File

@ -0,0 +1,297 @@
import { mdiTransmissionTower } 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 "../../../../components/ha-dialog";
import {
emptyFlowFromGridSourceEnergyPreference,
emptyFlowToGridSourceEnergyPreference,
FlowFromGridSourceEnergyPreference,
FlowToGridSourceEnergyPreference,
} from "../../../../data/energy";
import { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import { EnergySettingsGridFlowDialogParams } from "./show-dialogs-energy";
import "@material/mwc-button/mwc-button";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-radio";
import "../../../../components/ha-formfield";
import type { HaRadio } from "../../../../components/ha-radio";
import "../../../../components/entity/ha-entity-picker";
const energyUnits = ["kWh"];
@customElement("dialog-energy-grid-flow-settings")
export class DialogEnergyGridFlowSettings
extends LitElement
implements HassDialog<EnergySettingsGridFlowDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EnergySettingsGridFlowDialogParams;
@state() private _source?:
| FlowFromGridSourceEnergyPreference
| FlowToGridSourceEnergyPreference;
@state() private _costs?: "no-costs" | "number" | "entity" | "statistic";
@state() private _error?: string;
public async showDialog(
params: EnergySettingsGridFlowDialogParams
): Promise<void> {
this._params = params;
this._source = params.source
? { ...params.source }
: (this._source =
params.direction === "from"
? emptyFlowFromGridSourceEnergyPreference()
: emptyFlowToGridSourceEnergyPreference());
this._costs = this._source.entity_energy_price
? "entity"
: this._source.number_energy_price
? "number"
: this._source[
params.direction === "from" ? "stat_cost" : "stat_compensation"
]
? "statistic"
: "no-costs";
}
public closeDialog(): void {
this._params = undefined;
this._source = undefined;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
if (!this._params || !this._source) {
return html``;
}
return html`
<ha-dialog
open
.heading=${html`<ha-svg-icon
.path=${mdiTransmissionTower}
style="--mdc-icon-size: 32px;"
></ha-svg-icon
>${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.header`
)}`}
@closed=${this.closeDialog}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
<p>
${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.paragraph`
)}
</p>
<ha-statistic-picker
.hass=${this.hass}
.includeUnitOfMeasurement=${energyUnits}
.value=${this._source[
this._params.direction === "from"
? "stat_energy_from"
: "stat_energy_to"
]}
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.energy_stat`
)}
entities-only
@value-changed=${this._statisticChanged}
></ha-statistic-picker>
<p>
${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_para`
)}
</p>
<ha-formfield
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.no_cost`
)}
>
<ha-radio
value="no-costs"
name="costs"
.checked=${this._costs === "no-costs"}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_stat`
)}
>
<ha-radio
value="statistic"
name="costs"
.checked=${this._costs === "statistic"}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
${this._costs === "statistic"
? html`<ha-statistic-picker
class="price-options"
.hass=${this.hass}
statistic-types="sum"
.value=${this._source[
this._params!.direction === "from"
? "stat_cost"
: "stat_compensation"
]}
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_stat_input`
)}
@value-changed=${this._priceStatChanged}
></ha-statistic-picker>`
: ""}
<ha-formfield
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_entity`
)}
>
<ha-radio
value="entity"
name="costs"
.checked=${this._costs === "entity"}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
${this._costs === "entity"
? html`<ha-entity-picker
class="price-options"
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_entity_input`
)}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: ""}
<ha-formfield
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_number`
)}
>
<ha-radio
value="number"
name="costs"
.checked=${this._costs === "number"}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
${this._costs === "number"
? html`<paper-input
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_number_input`
)}
no-label-float
class="price-options"
step=".01"
type="number"
.value=${this._source.number_energy_price}
@value-changed=${this._numberPriceChanged}
>
<span slot="suffix"
>${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_number_suffix`,
{ currency: this._params.currency }
)}</span
>
</paper-input>`
: ""}
<mwc-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass.localize("ui.common.cancel")}
</mwc-button>
<mwc-button @click=${this._save} slot="primaryAction">
${this.hass.localize("ui.common.save")}
</mwc-button>
</ha-dialog>
`;
}
private _handleCostChanged(ev: CustomEvent) {
const input = ev.currentTarget as HaRadio;
this._costs = input.value as any;
}
private set _costStat(value: null | string) {
this._source![
this._params!.direction === "from" ? "stat_cost" : "stat_compensation"
] = value;
}
private _numberPriceChanged(ev: CustomEvent) {
this._source!.number_energy_price = Number(ev.detail.value);
this._source!.entity_energy_price = null;
this._costStat = null;
}
private _priceStatChanged(ev: CustomEvent) {
this._costStat = ev.detail.value;
this._source!.entity_energy_price = null;
this._source!.number_energy_price = null;
}
private _priceEntityChanged(ev: CustomEvent) {
this._source!.entity_energy_price = ev.detail.value;
this._source!.number_energy_price = null;
this._costStat = null;
}
private _statisticChanged(ev: CustomEvent<{ value: string }>) {
this._source![
this._params!.direction === "from" ? "stat_energy_from" : "stat_energy_to"
] = ev.detail.value;
this._source![
this._params!.direction === "from"
? "entity_energy_from"
: "entity_energy_to"
] = ev.detail.value;
}
private async _save() {
try {
if (this._costs === "no-costs") {
this._source!.entity_energy_price = null;
this._source!.number_energy_price = null;
this._costStat = null;
}
await this._params!.saveCallback(this._source!);
this.closeDialog();
} catch (e) {
this._error = e.message;
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-formfield {
display: block;
}
.price-options {
display: block;
padding-left: 52px;
margin-top: -16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-energy-grid-flow-settings": DialogEnergyGridFlowSettings;
}
}

View File

@ -0,0 +1,237 @@
import { mdiSolarPower } 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 "../../../../components/ha-dialog";
import {
emptySolarEnergyPreference,
SolarSourceTypeEnergyPreference,
} from "../../../../data/energy";
import { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import { EnergySettingsSolarDialogParams } from "./show-dialogs-energy";
import "@material/mwc-button/mwc-button";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-radio";
import "../../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../../components/ha-checkbox";
import "../../../../components/ha-formfield";
import "../../../../components/entity/ha-entity-picker";
import type { HaRadio } from "../../../../components/ha-radio";
import { showConfigFlowDialog } from "../../../../dialogs/config-flow/show-dialog-config-flow";
import { ConfigEntry, getConfigEntries } from "../../../../data/config_entries";
const energyUnits = ["kWh"];
@customElement("dialog-energy-solar-settings")
export class DialogEnergySolarSettings
extends LitElement
implements HassDialog<EnergySettingsSolarDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EnergySettingsSolarDialogParams;
@state() private _source?: SolarSourceTypeEnergyPreference;
@state() private _configEntries?: ConfigEntry[];
@state() private _forecast?: boolean;
@state() private _error?: string;
public async showDialog(
params: EnergySettingsSolarDialogParams
): Promise<void> {
this._fetchForecastSolarConfigEntries();
this._params = params;
this._source = params.source
? { ...params.source }
: (this._source = emptySolarEnergyPreference());
this._forecast = this._source.config_entry_solar_forecast !== null;
}
public closeDialog(): void {
this._params = undefined;
this._source = undefined;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
if (!this._params || !this._source) {
return html``;
}
return html`
<ha-dialog
open
.heading=${html`<ha-svg-icon
.path=${mdiSolarPower}
style="--mdc-icon-size: 32px;"
></ha-svg-icon>
Configure solar panels`}
@closed=${this.closeDialog}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
<p>Solar production for the win! <a href="#">Learn more</a></p>
<ha-statistic-picker
.hass=${this.hass}
.includeUnitOfMeasurement=${energyUnits}
.value=${this._source.stat_energy_from}
.label=${`Solar production energy (kWh)`}
entities-only
@value-changed=${this._statisticChanged}
></ha-statistic-picker>
<h3>Solar production forecast</h3>
<p>
We can predict how much energy your solar panels will produce, you can
link or setup an integration that will provide this data.
</p>
<ha-formfield label="Don't forecast production">
<ha-radio
value="false"
name="forecast"
.checked=${!this._forecast}
@change=${this._handleForecastChanged}
></ha-radio>
</ha-formfield>
<ha-formfield label="Forecast Production">
<ha-radio
value="true"
name="forecast"
.checked=${this._forecast}
@change=${this._handleForecastChanged}
></ha-radio>
</ha-formfield>
${this._forecast
? html`<div class="forecast-options">
${this._configEntries?.map(
(entry) => html`<ha-formfield
.label=${html`<div
style="display: flex; align-items: center;"
>
<img
referrerpolicy="no-referrer"
style="height: 24px; margin-right: 16px;"
src="https://brands.home-assistant.io/forecast_solar/icon.png"
/>${entry.title}
</div>`}
>
<ha-checkbox
.entry=${entry}
@change=${this._forecastCheckChanged}
.checked=${this._source?.config_entry_solar_forecast?.includes(
entry.entry_id
)}
>
</ha-checkbox>
</ha-formfield>`
)}
<mwc-button @click=${this._addForecast}>
Add forecast
</mwc-button>
</div>`
: ""}
<mwc-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass.localize("ui.common.cancel")}
</mwc-button>
<mwc-button @click=${this._save} slot="primaryAction">
${this.hass.localize("ui.common.save")}
</mwc-button>
</ha-dialog>
`;
}
private async _fetchForecastSolarConfigEntries() {
this._configEntries = (await getConfigEntries(this.hass)).filter(
(entry) => entry.domain === "forecast_solar"
);
}
private _handleForecastChanged(ev: CustomEvent) {
const input = ev.currentTarget as HaRadio;
this._forecast = input.value === "true";
}
private _forecastCheckChanged(ev) {
const input = ev.currentTarget as HaCheckbox;
const entry = (input as any).entry as ConfigEntry;
const checked = input.checked;
if (checked) {
if (this._source!.config_entry_solar_forecast === null) {
this._source!.config_entry_solar_forecast = [];
}
this._source!.config_entry_solar_forecast.push(entry.entry_id);
} else {
this._source!.config_entry_solar_forecast!.splice(
this._source!.config_entry_solar_forecast!.indexOf(entry.entry_id),
1
);
}
}
private _addForecast() {
showConfigFlowDialog(this, {
startFlowHandler: "forecast_solar",
dialogClosedCallback: (params) => {
if (params.entryId) {
if (this._source!.config_entry_solar_forecast === null) {
this._source!.config_entry_solar_forecast = [];
}
this._source!.config_entry_solar_forecast.push(params.entryId);
this._fetchForecastSolarConfigEntries();
}
},
});
}
private _statisticChanged(ev: CustomEvent<{ value: string }>) {
this._source!.stat_energy_from = ev.detail.value;
}
private async _save() {
try {
if (!this._forecast) {
this._source!.config_entry_solar_forecast = null;
}
await this._params!.saveCallback(this._source!);
this.closeDialog();
} catch (e) {
this._error = e.message;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
img {
height: 24px;
margin-right: 16px;
}
ha-formfield {
display: block;
}
.forecast-options {
padding-left: 32px;
}
.forecast-options mwc-button {
padding-left: 8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-energy-solar-settings": DialogEnergySolarSettings;
}
}

View File

@ -0,0 +1,85 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import {
DeviceConsumptionEnergyPreference,
FlowFromGridSourceEnergyPreference,
FlowToGridSourceEnergyPreference,
SolarSourceTypeEnergyPreference,
} from "../../../../data/energy";
export interface EnergySettingsGridFlowDialogParams {
source?:
| FlowFromGridSourceEnergyPreference
| FlowToGridSourceEnergyPreference;
currency: string;
direction: "from" | "to";
saveCallback: (
source:
| FlowFromGridSourceEnergyPreference
| FlowToGridSourceEnergyPreference
) => Promise<void>;
}
export interface EnergySettingsGridFlowFromDialogParams {
source?: FlowFromGridSourceEnergyPreference;
currency: string;
saveCallback: (source: FlowFromGridSourceEnergyPreference) => Promise<void>;
}
export interface EnergySettingsGridFlowToDialogParams {
source?: FlowToGridSourceEnergyPreference;
currency: string;
saveCallback: (source: FlowToGridSourceEnergyPreference) => Promise<void>;
}
export interface EnergySettingsSolarDialogParams {
source?: SolarSourceTypeEnergyPreference;
saveCallback: (source: SolarSourceTypeEnergyPreference) => Promise<void>;
}
export interface EnergySettingsDeviceDialogParams {
saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise<void>;
}
export const showEnergySettingsDeviceDialog = (
element: HTMLElement,
dialogParams: EnergySettingsDeviceDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-energy-device-settings",
dialogImport: () => import("./dialog-energy-device-settings"),
dialogParams: dialogParams,
});
};
export const showEnergySettingsSolarDialog = (
element: HTMLElement,
dialogParams: EnergySettingsSolarDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-energy-solar-settings",
dialogImport: () => import("./dialog-energy-solar-settings"),
dialogParams: dialogParams,
});
};
export const showEnergySettingsGridFlowFromDialog = (
element: HTMLElement,
dialogParams: EnergySettingsGridFlowFromDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-energy-grid-flow-settings",
dialogImport: () => import("./dialog-energy-grid-flow-settings"),
dialogParams: { ...dialogParams, direction: "from" },
});
};
export const showEnergySettingsGridFlowToDialog = (
element: HTMLElement,
dialogParams: EnergySettingsGridFlowToDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-energy-grid-flow-settings",
dialogImport: () => import("./dialog-energy-grid-flow-settings"),
dialogParams: { ...dialogParams, direction: "to" },
});
};

View File

@ -0,0 +1,164 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-svg-icon";
import {
EnergyPreferences,
getEnergyPreferences,
saveEnergyPreferences,
} from "../../../data/energy";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import "./components/ha-energy-grid-settings";
import "./components/ha-energy-solar-settings";
import "./components/ha-energy-device-settings";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
const INITIAL_CONFIG = {
currency: "€",
energy_sources: [],
device_consumption: [],
};
@customElement("ha-config-energy")
class HaConfigEnergy extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow!: boolean;
@property({ type: Boolean }) public isWide!: boolean;
@property({ type: Boolean }) public showAdvanced!: boolean;
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _preferences?: EnergyPreferences;
@state() private _error?: string;
protected firstUpdated() {
this._fetchConfig();
}
protected render(): TemplateResult {
if (!this._preferences && !this._error) {
return html`<hass-loading-screen
.hass=${this.hass}
.narrow=${this.narrow}
></hass-loading-screen>`;
}
if (this._error) {
return html`<hass-error-screen
.hass=${this.hass}
.narrow=${this.narrow}
.error=${this._error}
></hass-error-screen>`;
}
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${this._searchParms.has("historyBack")
? undefined
: "/config"}
.route=${this.route}
.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">
<ha-energy-grid-settings
.hass=${this.hass}
.preferences=${this._preferences!}
@value-changed=${this._prefsChanged}
></ha-energy-grid-settings>
<ha-energy-solar-settings
.hass=${this.hass}
.preferences=${this._preferences!}
@value-changed=${this._prefsChanged}
></ha-energy-solar-settings>
<ha-energy-device-settings
.hass=${this.hass}
.preferences=${this._preferences!}
@value-changed=${this._prefsChanged}
></ha-energy-device-settings>
</div>
</hass-tabs-subpage>
`;
}
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() {
try {
this._preferences = await getEnergyPreferences(this.hass);
} catch (e) {
if (e.code === "not_found") {
this._preferences = INITIAL_CONFIG;
} else {
this._error = e.message;
}
}
}
private _prefsChanged(ev: CustomEvent) {
this._preferences = ev.detail.value;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-card {
margin: 8px;
}
.container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
grid-gap: 8px 8px;
padding: 8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-energy": HaConfigEnergy;
}
}

View File

@ -4,6 +4,7 @@ import {
mdiDevices,
mdiHomeAssistant,
mdiInformation,
mdiLightningBolt,
mdiMapMarkerRadius,
mdiMathLog,
mdiNfcVariant,
@ -105,13 +106,19 @@ export const configSections: { [name: string]: PageNavigation[] } = {
core: true,
},
],
experimental: [
experiences: [
{
component: "tag",
path: "/config/tags",
translationKey: "ui.panel.config.tag.caption",
iconPath: mdiNfcVariant,
},
{
component: "energy",
path: "/config/energy",
translationKey: "ui.panel.config.energy.caption",
iconPath: mdiLightningBolt,
},
],
lovelace: [
{
@ -248,6 +255,10 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-entities",
load: () => import("./entities/ha-config-entities"),
},
energy: {
tag: "ha-config-energy",
load: () => import("./energy/ha-config-energy"),
},
integrations: {
tag: "ha-config-integrations",
load: () => import("./integrations/ha-config-integrations"),

View File

@ -265,7 +265,10 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
protected render(): TemplateResult {
if (!this._configEntries) {
return html`<hass-loading-screen></hass-loading-screen>`;
return html`<hass-loading-screen
.hass=${this.hass}
.narrow=${this.narrow}
></hass-loading-screen>`;
}
const [
groupedConfigEntries,

View File

@ -184,7 +184,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.experimental}
.tabs=${configSections.experiences}
.columns=${this._columns(
this.narrow,
this._canWriteTags,

View File

@ -0,0 +1,142 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { EnergyPreferences, saveEnergyPreferences } from "../../../data/energy";
import { LovelaceCardConfig } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types";
import { LovelaceCard, Lovelace } from "../../lovelace/types";
import "@material/mwc-button/mwc-button";
import "../../config/energy/components/ha-energy-grid-settings";
import "../../config/energy/components/ha-energy-solar-settings";
import "../../config/energy/components/ha-energy-device-settings";
import { haStyle } from "../../../resources/styles";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
@customElement("energy-setup-wizard-card")
export class EnergySetupWizard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public lovelace?: Lovelace;
@state() private _step = 0;
private _preferences: EnergyPreferences = {
currency: "€",
energy_sources: [],
device_consumption: [],
};
public getCardSize() {
return 10;
}
public setConfig(config: LovelaceCardConfig) {
if (config.preferences) {
this._preferences = config.preferences;
}
}
protected firstUpdated() {
this.hass.loadFragmentTranslation("config");
}
protected render(): TemplateResult {
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>
${this._step === 0
? html` <ha-energy-grid-settings
.hass=${this.hass}
.preferences=${this._preferences}
@value-changed=${this._prefsChanged}
></ha-energy-grid-settings>`
: this._step === 1
? html` <ha-energy-solar-settings
.hass=${this.hass}
.preferences=${this._preferences}
@value-changed=${this._prefsChanged}
></ha-energy-solar-settings>`
: html` <ha-energy-device-settings
.hass=${this.hass}
.preferences=${this._preferences}
@value-changed=${this._prefsChanged}
></ha-energy-device-settings>`}
<div class="buttons">
${this._step > 0
? html`<mwc-button @click=${this._back}
>${this.hass.localize("ui.panel.energy.setup.back")}</mwc-button
>`
: html`<div></div>`}
${this._step < 2
? html`<mwc-button outlined @click=${this._next}
>${this.hass.localize("ui.panel.energy.setup.next")}</mwc-button
>`
: html`<mwc-button raised @click=${this._setupDone}>
${this.hass.localize("ui.panel.energy.setup.done")}
</mwc-button>`}
</div>
`;
}
private _prefsChanged(ev: CustomEvent) {
this._preferences = ev.detail.value;
}
private _back() {
if (this._step === 0) {
return;
}
this._step--;
}
private _next() {
if (this._step === 2) {
return;
}
this._step++;
}
private async _setupDone() {
if (!this._preferences) {
return;
}
try {
this._preferences = await saveEnergyPreferences(
this.hass,
this._preferences
);
} catch (err) {
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
}
fireEvent(this, "reload-energy-panel");
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
padding: 16px;
max-width: 700px;
margin: 0 auto;
}
mwc-button {
margin-top: 8px;
}
.buttons {
display: flex;
justify-content: space-between;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"energy-setup-wizard-card": EnergySetupWizard;
}
}

View File

@ -0,0 +1,130 @@
import "@polymer/app-layout/app-header/app-header";
import "@polymer/app-layout/app-toolbar/app-toolbar";
import "@material/mwc-tab";
import "@material/mwc-tab-bar";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-menu-button";
import "../../layouts/ha-app-layout";
import { mdiCog } from "@mdi/js";
import { haStyle } from "../../resources/styles";
import "../lovelace/views/hui-view";
import { HomeAssistant } from "../../types";
import { Lovelace } from "../lovelace/types";
import { LovelaceConfig } from "../../data/lovelace";
const LOVELACE_CONFIG: LovelaceConfig = {
views: [
{
strategy: {
type: "energy",
},
},
],
};
@customElement("ha-panel-energy")
class PanelEnergy extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow!: boolean;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass?.locale !== this.hass.locale) {
this._setLovelace();
}
}
protected render(): TemplateResult {
return html`
<ha-app-layout>
<app-header fixed slot="header">
<app-toolbar>
<ha-menu-button
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
<div main-title>${this.hass.localize("panel.energy")}</div>
<a href="/config/energy?historyBack=1">
<mwc-icon-button>
<ha-svg-icon .path=${mdiCog}></ha-svg-icon>
</mwc-icon-button>
</a>
</app-toolbar>
</app-header>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@reload-energy-panel=${this._reloadView}
></hui-view>
</ha-app-layout>
`;
}
private _setLovelace() {
this._lovelace = {
config: LOVELACE_CONFIG,
rawConfig: LOVELACE_CONFIG,
editMode: false,
urlPath: "energy",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
};
}
private _reloadView() {
// Force strategy to be re-run by make a copy of the view
const config = this._lovelace!.config;
this._lovelace = {
...this._lovelace!,
config: { ...config, views: [{ ...config.views[0] }] },
};
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
mwc-icon-button {
color: var(--text-primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-panel-energy": PanelEnergy;
}
}
declare global {
interface HASSDomEvents {
"reload-energy-panel": undefined;
}
}

View File

@ -0,0 +1,120 @@
import { EnergyPreferences, getEnergyPreferences } from "../../../data/energy";
import { LovelaceViewConfig } from "../../../data/lovelace";
import { LovelaceViewStrategy } from "../../lovelace/strategies/get-strategy";
const setupWizard = async (): Promise<LovelaceViewConfig> => {
await import("../cards/energy-setup-wizard-card");
return {
type: "panel",
cards: [
{
type: "custom:energy-setup-wizard-card",
},
],
};
};
export class EnergyStrategy {
static async generateView(
info: Parameters<LovelaceViewStrategy["generateView"]>[0]
): ReturnType<LovelaceViewStrategy["generateView"]> {
const hass = info.hass;
const view: LovelaceViewConfig = { cards: [] };
let energyPrefs: EnergyPreferences;
try {
energyPrefs = await getEnergyPreferences(hass);
} catch (e) {
if (e.code === "not_found") {
return setupWizard();
}
view.cards!.push({
type: "markdown",
content: `An error occured while fetching your energy preferences: ${e.message}.`,
});
return view;
}
view.type = "sidebar";
const hasGrid = energyPrefs.energy_sources.some(
(source) => source.type === "grid"
);
const hasSolar = energyPrefs.energy_sources.some(
(source) => source.type === "solar"
);
// Only include if we have a grid source.
if (hasGrid) {
view.cards!.push({
title: "Electricity",
type: "energy-summary-graph",
prefs: energyPrefs,
});
}
// Only include if we have a solar source.
if (hasSolar) {
view.cards!.push({
title: "Solar production",
type: "energy-solar-graph",
prefs: energyPrefs,
});
}
// Only include if we have a grid.
if (hasGrid) {
view.cards!.push({
title: "Costs",
type: "energy-costs-table",
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,
view_layout: { position: "sidebar" },
});
}
// Only include if we have a solar source.
if (hasSolar) {
view.cards!.push({
type: "energy-solar-consumed-gauge",
prefs: energyPrefs,
view_layout: { position: "sidebar" },
});
}
// Only include if we have a grid
if (hasGrid) {
view.cards!.push({
type: "energy-carbon-consumed-gauge",
prefs: energyPrefs,
view_layout: { position: "sidebar" },
});
}
view.cards!.push({
type: "energy-summary",
prefs: energyPrefs,
view_layout: { position: "sidebar" },
});
return view;
}
}

View File

@ -0,0 +1,233 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { round } from "../../../common/number/round";
import { subscribeOne } from "../../../common/util/subscribe-one";
import "../../../components/ha-card";
import "../../../components/ha-gauge";
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 type { HomeAssistant } from "../../../types";
import type { LovelaceCard } from "../types";
import { severityMap } from "./hui-gauge-card";
import type { EnergyCarbonGaugeCardConfig } from "./types";
@customElement("hui-energy-carbon-consumed-gauge-card")
class HuiEnergyCarbonGaugeCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergyCarbonGaugeCardConfig;
@state() private _stats?: Statistics;
@state() private _co2SignalEntity?: string | null;
public getCardSize(): number {
return 4;
}
public setConfig(config: EnergyCarbonGaugeCardConfig): void {
this._config = config;
}
public willUpdate(changedProps) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._getStatistics();
this._fetchCO2SignalEntity();
}
}
protected render(): TemplateResult {
if (!this._config || !this.hass) {
return html``;
}
if (!this._stats || this._co2SignalEntity === undefined) {
return html`Loading...`;
}
if (!this._co2SignalEntity) {
return html``;
}
const co2State = this.hass.states[this._co2SignalEntity];
if (!co2State) {
return html`No CO2 Signal entity found.`;
}
const co2percentage = Number(co2State.state);
if (isNaN(co2percentage)) {
return html``;
}
const prefs = this._config!.prefs;
const types = energySourcesByType(prefs);
const totalGridConsumption = calculateStatisticsSumGrowth(
this._stats,
types.grid![0].flow_from.map((flow) => flow.stat_energy_from)
);
const totalSolarProduction = types.solar
? 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)
);
if (totalGridConsumption === null) {
return html`Couldn't calculate the total grid consumption.`;
}
const highCarbonEnergy = (totalGridConsumption * co2percentage) / 100;
const totalEnergyConsumed =
totalGridConsumption +
(totalSolarProduction || 0) -
(totalGridReturned || 0);
const value = round((highCarbonEnergy / totalEnergyConsumed) * 100);
return html`
<ha-card>
<ha-gauge
min="0"
max="100"
.value=${value}
.locale=${this.hass!.locale}
label="%"
style=${styleMap({
"--gauge-color": this._computeSeverity(64),
})}
></ha-gauge>
<div class="name">High-carbon energy consumed</div>
</ha-card>
`;
}
private _computeSeverity(numberValue: number): string {
if (numberValue > 50) {
return severityMap.red;
}
if (numberValue > 30) {
return severityMap.yellow;
}
if (numberValue < 10) {
return severityMap.green;
}
return severityMap.normal;
}
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) {
this._co2SignalEntity = null;
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;
return;
}
this._co2SignalEntity = null;
}
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 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 {
--gauge-color: var(--label-badge-blue);
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-carbon-consumed-gauge-card": HuiEnergyCarbonGaugeCard;
}
}

View File

@ -0,0 +1,252 @@
// @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

@ -0,0 +1,254 @@
import {
ChartData,
ChartDataset,
ChartOptions,
ParsedDataType,
} from "chart.js";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { getColorByIndex } from "../../../common/color/colors";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/chart/ha-chart-base";
import "../../../components/ha-card";
import {
calculateStatisticSumGrowth,
fetchStatistics,
Statistics,
} from "../../../data/history";
import { HomeAssistant } from "../../../types";
import { LovelaceCard } from "../types";
import { EnergyDevicesGraphCardConfig } from "./types";
@customElement("hui-energy-devices-graph-card")
export class HuiEnergyDevicesGraphCard
extends LitElement
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergyDevicesGraphCardConfig;
@state() private _data?: Statistics;
@state() private _chartData?: ChartData;
@state() private _chartOptions?: ChartOptions;
private _fetching = false;
private _interval?: number;
public disconnectedCallback() {
super.disconnectedCallback();
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
public connectedCallback() {
super.connectedCallback();
if (!this.hasUpdated) {
return;
}
this._getStatistics();
// statistics are created every hour
clearInterval(this._interval);
this._interval = window.setInterval(
() => this._getStatistics(),
1000 * 60 * 60
);
}
public getCardSize(): Promise<number> | number {
return 3;
}
public setConfig(config: EnergyDevicesGraphCardConfig): void {
this._config = config;
}
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._createOptions();
}
if (!this._config || !changedProps.has("_config")) {
return;
}
const oldConfig = changedProps.get("_config") as
| EnergyDevicesGraphCardConfig
| undefined;
if (oldConfig !== this._config) {
this._getStatistics();
// statistics are created every hour
clearInterval(this._interval);
this._interval = window.setInterval(
() => this._getStatistics(),
1000 * 60 * 60
);
}
}
protected render(): TemplateResult {
if (!this.hass || !this._config) {
return html``;
}
return html`
<ha-card .header="${this._config.title}">
<div
class="content ${classMap({
"has-header": !!this._config.title,
})}"
>
${this._chartData
? html`<ha-chart-base
.data=${this._chartData}
.options=${this._chartOptions}
chart-type="bar"
></ha-chart-base>`
: ""}
</div>
</ha-card>
`;
}
private _createOptions() {
this._chartOptions = {
parsing: false,
animation: false,
responsive: true,
indexAxis: "y",
scales: {
x: {
title: {
display: true,
text: "kWh",
},
},
},
elements: { bar: { borderWidth: 1.5 } },
plugins: {
tooltip: {
mode: "nearest",
callbacks: {
label: (context) =>
`${context.dataset.label}: ${
Math.round(context.parsed.x * 100) / 100
} kWh`,
},
},
},
};
}
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 prefs = this._config!.prefs;
try {
this._data = await fetchStatistics(
this.hass!,
startDate,
undefined,
prefs.device_consumption.map((device) => device.stat_consumption)
);
} finally {
this._fetching = false;
}
const statisticsData = Object.values(this._data!);
let endTime: Date;
if (statisticsData.length === 0) {
return;
}
endTime = new Date(
Math.max(
...statisticsData.map((stats) =>
new Date(stats[stats.length - 1].start).getTime()
)
)
);
if (endTime > new Date()) {
endTime = new Date();
}
const data: Array<ChartDataset<"bar", ParsedDataType<"bar">>["data"]> = [];
const borderColor: string[] = [];
const backgroundColor: string[] = [];
const datasets: ChartDataset<"bar", ParsedDataType<"bar">[]>[] = [
{
label: "Energy usage",
borderColor,
backgroundColor,
data,
},
];
Object.entries(this._data).forEach(([id, statistics], idx) => {
const entity = this.hass.states[id];
const label = entity ? computeStateName(entity) : id;
const color = getColorByIndex(idx);
borderColor.push(color);
backgroundColor.push(color + "7F");
const value = calculateStatisticSumGrowth(statistics);
data.push({
// @ts-expect-error
y: label,
x: value || 0,
});
});
data.sort((a, b) => b.x - a.x);
this._chartData = {
// labels,
datasets,
};
}
static get styles(): CSSResultGroup {
return css`
ha-card {
height: 100%;
}
.content {
padding: 16px;
}
.has-header {
padding-top: 0;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-devices-graph-card": HuiEnergyDevicesGraphCard;
}
}

View File

@ -0,0 +1,161 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { round } from "../../../common/number/round";
import "../../../components/ha-card";
import "../../../components/ha-gauge";
import { energySourcesByType } from "../../../data/energy";
import {
calculateStatisticsSumGrowth,
fetchStatistics,
Statistics,
} from "../../../data/history";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCard } from "../types";
import { severityMap } from "./hui-gauge-card";
import type { EnergySolarGaugeCardConfig } from "./types";
@customElement("hui-energy-solar-consumed-gauge-card")
class HuiEnergySolarGaugeCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: EnergySolarGaugeCardConfig;
@state() private _stats?: Statistics;
public getCardSize(): number {
return 4;
}
public setConfig(config: EnergySolarGaugeCardConfig): 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 types = energySourcesByType(prefs);
const totalSolarProduction = calculateStatisticsSumGrowth(
this._stats,
types.solar!.map((source) => source.stat_energy_from)
);
const productionReturnedToGrid = calculateStatisticsSumGrowth(
this._stats,
types.grid![0].flow_to.map((flow) => flow.stat_energy_to)
);
let value: number | undefined;
if (productionReturnedToGrid !== null && totalSolarProduction !== null) {
const cosumedSolar = totalSolarProduction - productionReturnedToGrid;
value = round((cosumedSolar / totalSolarProduction) * 100);
}
return html`
<ha-card>
${value
? html` <ha-gauge
min="0"
max="100"
.value=${value}
.locale=${this.hass!.locale}
label="%"
style=${styleMap({
"--gauge-color": this._computeSeverity(64),
})}
></ha-gauge>
<div class="name">Self consumed solar energy</div>`
: html`Self consumed solar energy couldn't be calculated`}
</ha-card>
`;
}
private _computeSeverity(numberValue: number): string {
if (numberValue > 50) {
return severityMap.green;
}
return severityMap.normal;
}
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 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 {
--gauge-color: var(--label-badge-blue);
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-solar-consumed-gauge-card": HuiEnergySolarGaugeCard;
}
}

View File

@ -0,0 +1,412 @@
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
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 { EnergySolarGraphCardConfig } from "./types";
import { fetchStatistics, Statistics } from "../../../data/history";
import {
hex2rgb,
lab2rgb,
rgb2hex,
rgb2lab,
} from "../../../common/color/convert-color";
import { labDarken } from "../../../common/color/lab";
import { SolarSourceTypeEnergyPreference } from "../../../data/energy";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import {
ForecastSolarForecast,
getForecastSolarForecasts,
} from "../../../data/forecast_solar";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/chart/ha-chart-base";
import "../../../components/ha-switch";
import "../../../components/ha-formfield";
const SOLAR_COLOR = { border: "#FF9800", background: "#ffcb80" };
@customElement("hui-energy-solar-graph-card")
export class HuiEnergySolarGraphCard
extends LitElement
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergySolarGraphCardConfig;
@state() private _data?: Statistics;
@state() private _chartData?: ChartData;
@state() private _forecasts?: Record<string, ForecastSolarForecast>;
@state() private _chartOptions?: ChartOptions;
@state() private _showAllForecastData = false;
private _fetching = false;
private _interval?: number;
public disconnectedCallback() {
super.disconnectedCallback();
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
public connectedCallback() {
super.connectedCallback();
if (!this.hasUpdated) {
return;
}
this._getStatistics();
// statistics are created every hour
clearInterval(this._interval);
this._interval = window.setInterval(
() => this._getStatistics(),
1000 * 60 * 60
);
}
public getCardSize(): Promise<number> | number {
return 3;
}
public setConfig(config: EnergySolarGraphCardConfig): void {
this._config = config;
}
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._createOptions();
}
if (!this._config || !changedProps.has("_config")) {
return;
}
const oldConfig = changedProps.get("_config") as
| EnergySolarGraphCardConfig
| undefined;
if (oldConfig !== this._config) {
this._getStatistics();
// statistics are created every hour
clearInterval(this._interval);
this._interval = window.setInterval(
() => this._getStatistics(),
1000 * 60 * 60
);
}
}
protected render(): TemplateResult {
if (!this.hass || !this._config) {
return html``;
}
return html`
<ha-card .header="${this._config.title}">
<div
class="content ${classMap({
"has-header": !!this._config.title,
})}"
>
<ha-formfield label="Show all forecast data"
><ha-switch
.checked=${this._showAllForecastData}
@change=${this._showAllForecastChanged}
></ha-switch
></ha-formfield>
${this._chartData
? html`<ha-chart-base
.data=${this._chartData}
.options=${this._chartOptions}
chart-type="line"
></ha-chart-base>`
: ""}
</div>
</ha-card>
`;
}
private _createOptions() {
this._chartOptions = {
parsing: false,
animation: false,
scales: {
x: {
type: "time",
adapters: {
date: {
locale: this.hass.locale,
},
},
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat: "datetimeseconds",
},
},
y: {
type: "linear",
ticks: {
beginAtZero: true,
},
},
},
plugins: {
tooltip: {
mode: "nearest",
callbacks: {
label: (context) =>
`${context.dataset.label}: ${context.parsed.y} kWh`,
},
},
filler: {
propagate: false,
},
legend: {
display: false,
labels: {
usePointStyle: true,
},
},
},
hover: {
mode: "nearest",
},
elements: {
line: {
tension: 0.4,
borderWidth: 1.5,
},
point: {
hitRadius: 5,
},
},
};
}
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 solarSources: SolarSourceTypeEnergyPreference[] =
this._config!.prefs.energy_sources.filter(
(source) => source.type === "solar"
) as SolarSourceTypeEnergyPreference[];
try {
this._data = await fetchStatistics(
this.hass!,
startDate,
undefined,
solarSources.map((source) => source.stat_energy_from)
);
} finally {
this._fetching = false;
}
if (
isComponentLoaded(this.hass, "forecast_solar") &&
solarSources.some((source) => source.config_entry_solar_forecast)
) {
this._forecasts = await getForecastSolarForecasts(this.hass);
}
this._renderChart();
}
private _renderChart() {
const solarSources: SolarSourceTypeEnergyPreference[] =
this._config!.prefs.energy_sources.filter(
(source) => source.type === "solar"
) as SolarSourceTypeEnergyPreference[];
const statisticsData = Object.values(this._data!);
const datasets: ChartDataset<"line">[] = [];
let endTime: Date;
if (statisticsData.length === 0) {
return;
}
endTime = new Date(
Math.max(
...statisticsData.map((stats) =>
new Date(stats[stats.length - 1].start).getTime()
)
)
);
if (endTime > new Date()) {
endTime = new Date();
}
solarSources.forEach((source, idx) => {
const data: ChartDataset<"line">[] = [];
const entity = this.hass.states[source.stat_energy_from];
const borderColor =
idx > 0
? rgb2hex(
lab2rgb(labDarken(rgb2lab(hex2rgb(SOLAR_COLOR.border)), idx))
)
: SOLAR_COLOR.border;
data.push({
label: `Production ${
entity ? computeStateName(entity) : source.stat_energy_from
}`,
fill: true,
stepped: false,
borderColor: borderColor,
backgroundColor: borderColor + "7F",
data: [],
});
let prevValue: number | null = null;
let prevStart: string | null = null;
// Process solar production data.
if (this._data![source.stat_energy_from]) {
for (const point of this._data![source.stat_energy_from]) {
if (!point.sum) {
continue;
}
if (prevValue === null) {
prevValue = point.sum;
continue;
}
if (prevStart === point.start) {
continue;
}
const value = Math.round((point.sum - prevValue) * 100) / 100;
const date = new Date(point.start);
data[0].data.push({
x: date.getTime(),
y: value,
});
prevStart = point.start;
prevValue = point.sum;
}
}
const forecasts = this._forecasts;
// Process solar forecast data.
if (forecasts && source.config_entry_solar_forecast) {
let forecastsData: Record<string, number> | undefined;
source.config_entry_solar_forecast.forEach((configEntryId) => {
if (!forecastsData) {
forecastsData = forecasts![configEntryId]?.wh_hours;
return;
}
Object.entries(forecasts![configEntryId].wh_hours).forEach(
([date, value]) => {
if (date in forecastsData!) {
forecastsData![date] += value;
} else {
forecastsData![date] = value;
}
}
);
});
if (forecastsData) {
const forecast: ChartDataset<"line"> = {
label: `Forecast ${
entity ? computeStateName(entity) : source.stat_energy_from
}`,
fill: false,
stepped: false,
borderColor: "#000",
borderDash: [7, 5],
pointRadius: 0,
data: [],
};
data.push(forecast);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
for (const [date, value] of Object.entries(forecastsData)) {
const dateObj = new Date(date);
if (dateObj > tomorrow && !this._showAllForecastData) {
continue;
}
forecast.data.push({
x: dateObj.getTime(),
y: value / 1000,
});
}
}
}
// Concat two arrays
Array.prototype.push.apply(datasets, data);
});
this._chartData = {
datasets,
};
}
private _showAllForecastChanged(ev) {
this._showAllForecastData = ev.target.checked;
this._renderChart();
}
static get styles(): CSSResultGroup {
return css`
ha-card {
height: 100%;
}
.content {
padding: 16px;
}
.has-header {
padding-top: 0;
}
ha-formfield {
margin-bottom: 16px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-solar-graph-card": HuiEnergySolarGraphCard;
}
}

View File

@ -0,0 +1,302 @@
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

@ -0,0 +1,440 @@
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
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 {
hex2rgb,
lab2rgb,
rgb2hex,
rgb2lab,
} from "../../../common/color/convert-color";
import { labDarken } from "../../../common/color/lab";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/chart/ha-chart-base";
import { round } from "../../../common/number/round";
const NEGATIVE = ["to_grid"];
const ORDER = {
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
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergySummaryGraphCardConfig;
@state() private _data?: Statistics;
@state() private _chartData?: ChartData;
@state() private _chartOptions?: ChartOptions;
private _fetching = false;
private _interval?: number;
public disconnectedCallback() {
super.disconnectedCallback();
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
public connectedCallback() {
super.connectedCallback();
if (!this.hasUpdated) {
return;
}
this._getStatistics();
// statistics are created every hour
clearInterval(this._interval);
this._interval = window.setInterval(
() => this._getStatistics(),
1000 * 60 * 60
);
}
public getCardSize(): Promise<number> | number {
return 3;
}
public setConfig(config: EnergySummaryGraphCardConfig): void {
this._config = config;
}
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._createOptions();
}
if (!this._config || !changedProps.has("_config")) {
return;
}
const oldConfig = changedProps.get("_config") as
| EnergySummaryGraphCardConfig
| undefined;
if (oldConfig !== this._config) {
this._getStatistics();
// statistics are created every hour
clearInterval(this._interval);
this._interval = window.setInterval(
() => this._getStatistics(),
1000 * 60 * 60
);
}
}
protected render(): TemplateResult {
if (!this.hass || !this._config) {
return html``;
}
return html`
<ha-card .header="${this._config.title}">
<div
class="content ${classMap({
"has-header": !!this._config.title,
})}"
>
${this._chartData
? html`<ha-chart-base
.data=${this._chartData}
.options=${this._chartOptions}
chartType="line"
></ha-chart-base>`
: ""}
</div>
</ha-card>
`;
}
private _createOptions() {
this._chartOptions = {
parsing: false,
animation: false,
scales: {
x: {
type: "time",
adapters: {
date: {
locale: this.hass.locale,
},
},
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat: "datetimeseconds",
},
},
y: {
stacked: true,
type: "linear",
ticks: {
beginAtZero: true,
callback: (value) => Math.abs(round(value)),
},
},
},
plugins: {
tooltip: {
mode: "x",
intersect: true,
position: "nearest",
filter: (val) => val.formattedValue !== "0",
callbacks: {
label: (context) =>
`${context.dataset.label}: ${Math.abs(context.parsed.y)} kWh`,
footer: (contexts) => {
let totalConsumed = 0;
let totalReturned = 0;
for (const context of contexts) {
const value = (context.dataset.data[context.dataIndex] as any)
.y;
if (value > 0) {
totalConsumed += value;
} else {
totalReturned += Math.abs(value);
}
}
return [
`Total consumed: ${totalConsumed.toFixed(2)} kWh`,
`Total returned: ${totalReturned.toFixed(2)} kWh`,
];
},
},
},
filler: {
propagate: false,
},
legend: {
display: false,
labels: {
usePointStyle: true,
},
},
},
hover: {
mode: "nearest",
},
elements: {
line: {
tension: 0.4,
borderWidth: 1.5,
},
point: {
hitRadius: 5,
},
},
};
}
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 prefs = this._config!.prefs;
const statistics: {
to_grid?: string[];
from_grid?: string[];
solar?: string[];
} = {};
for (const source of prefs.energy_sources) {
if (source.type === "solar") {
if (statistics.solar) {
statistics.solar.push(source.stat_energy_from);
} else {
statistics.solar = [source.stat_energy_from];
}
continue;
}
// grid source
for (const flowFrom of source.flow_from) {
if (statistics.from_grid) {
statistics.from_grid.push(flowFrom.stat_energy_from);
} else {
statistics.from_grid = [flowFrom.stat_energy_from];
}
}
for (const flowTo of source.flow_to) {
if (statistics.to_grid) {
statistics.to_grid.push(flowTo.stat_energy_to);
} else {
statistics.to_grid = [flowTo.stat_energy_to];
}
}
}
try {
this._data = await fetchStatistics(
this.hass!,
startDate,
undefined,
// Array.flat()
([] as string[]).concat(...Object.values(statistics))
);
} finally {
this._fetching = false;
}
const statisticsData = Object.values(this._data!);
const datasets: ChartDataset<"line">[] = [];
let endTime: Date;
if (statisticsData.length === 0) {
return;
}
endTime = new Date(
Math.max(
...statisticsData.map((stats) =>
new Date(stats[stats.length - 1].start).getTime()
)
)
);
if (endTime > new Date()) {
endTime = new Date();
}
const combinedData: {
[key: string]: { [statId: string]: { [start: string]: number } };
} = {};
const summedData: { [key: string]: { [start: string]: number } } = {};
Object.entries(statistics).forEach(([key, statIds]) => {
const sum = ["solar", "to_grid"].includes(key);
const add = key !== "solar";
const totalStats: { [start: string]: number } = {};
const sets: { [statId: string]: { [start: string]: number } } = {};
statIds!.forEach((id) => {
const stats = this._data![id];
if (!stats) {
return;
}
const set = {};
let prevValue: number;
stats.forEach((stat) => {
if (!stat.sum) {
return;
}
if (!prevValue) {
prevValue = stat.sum;
return;
}
const val = stat.sum - prevValue;
// Get total of solar and to grid to calculate the solar energy used
if (sum) {
totalStats[stat.start] =
stat.start in totalStats ? totalStats[stat.start] + val : val;
}
if (add) {
set[stat.start] = val;
}
prevValue = stat.sum;
});
sets[id] = set;
});
if (sum) {
summedData[key] = totalStats;
}
if (add) {
combinedData[key] = sets;
}
});
if (summedData.to_grid && summedData.solar) {
const used_solar = {};
for (const start of Object.keys(summedData.solar)) {
used_solar[start] = Math.max(
(summedData.solar[start] || 0) - (summedData.to_grid[start] || 0),
0
);
}
combinedData.used_solar = { used_solar: used_solar };
}
let allKeys: string[] = [];
Object.values(combinedData).forEach((sources) => {
Object.values(sources).forEach((source) => {
allKeys = allKeys.concat(Object.keys(source));
});
});
const uniqueKeys = Array.from(new Set(allKeys));
Object.entries(combinedData).forEach(([type, sources]) => {
const negative = NEGATIVE.includes(type);
Object.entries(sources).forEach(([statId, source], idx) => {
const data: ChartDataset<"line">[] = [];
const entity = this.hass.states[statId];
const color = COLORS[type];
data.push({
label:
type === "used_solar"
? "Solar"
: entity
? computeStateName(entity)
: statId,
fill: true,
stepped: false,
order: ORDER[type] + idx,
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: [],
});
// Process chart data.
for (const key of uniqueKeys) {
const value = key in source ? Math.round(source[key] * 100) / 100 : 0;
const date = new Date(key);
data[0].data.push({
x: date.getTime(),
y: value && negative ? -1 * value : value,
});
}
// Concat two arrays
Array.prototype.push.apply(datasets, data);
});
});
this._chartData = {
datasets,
};
}
static get styles(): CSSResultGroup {
return css`
ha-card {
height: 100%;
}
.content {
padding: 16px;
}
.has-header {
padding-top: 0;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-summary-graph-card": HuiEnergySummaryGraphCard;
}
}

View File

@ -0,0 +1,333 @@
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

@ -1,3 +1,4 @@
import { EnergyPreferences } from "../../../data/energy";
import { StatisticType } from "../../../data/history";
import { ActionConfig, LovelaceCardConfig } from "../../../data/lovelace";
import { FullCalendarView } from "../../../types";
@ -89,6 +90,36 @@ export interface ButtonCardConfig extends LovelaceCardConfig {
show_state?: boolean;
}
export interface EnergySummaryCardConfig extends LovelaceCardConfig {
type: "energy-summary";
prefs: EnergyPreferences;
}
export interface EnergySummaryGraphCardConfig extends LovelaceCardConfig {
type: "energy-summary-graph";
prefs: EnergyPreferences;
}
export interface EnergySolarGraphCardConfig extends LovelaceCardConfig {
type: "energy-solar-graph";
prefs: EnergyPreferences;
}
export interface EnergyDevicesGraphCardConfig extends LovelaceCardConfig {
type: "energy-devices-graph";
prefs: EnergyPreferences;
}
export interface EnergySolarGaugeCardConfig extends LovelaceCardConfig {
type: "energy-solar-consumed-gauge";
prefs: EnergyPreferences;
}
export interface EnergyCarbonGaugeCardConfig extends LovelaceCardConfig {
type: "energy-carbon-consumed-gauge";
prefs: EnergyPreferences;
}
export interface EntityFilterCardConfig extends LovelaceCardConfig {
type: "entity-filter";
entities: Array<EntityFilterEntityConfig | string>;
@ -332,3 +363,58 @@ export interface WeatherForecastCardConfig extends LovelaceCardConfig {
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
}
export interface EnergyFlowCardConfig extends LovelaceCardConfig {
type: string;
name?: string;
show_header_toggle?: boolean;
show_warning?: boolean;
show_error?: boolean;
test_gui?: boolean;
show_w_not_kw?: any;
hide_inactive_lines?: boolean;
threshold_in_k?: number;
energy_flow_diagramm?: boolean;
energy_flow_diagramm_lines_factor?: number;
change_house_bubble_color_with_flow?: boolean;
grid_icon?: string;
generation_icon?: string;
house_icon?: string;
battery_icon?: string;
appliance1_icon?: string;
appliance2_icon?: string;
icon_entities?: Map<string, string>;
line_entities?: Map<string, string>;
house_entity?: string;
battery_entity?: string;
generation_entity?: string;
grid_entity?: string;
grid_to_house_entity?: string;
grid_to_battery_entity?: string;
generation_to_grid_entity?: string;
generation_to_battery_entity?: string;
generation_to_house_entity?: string;
battery_to_house_entity?: string;
battery_to_grid_entity?: string;
grid_extra_entity?: string;
generation_extra_entity?: string;
house_extra_entity?: string;
battery_extra_entity?: string;
appliance1_consumption_entity?: string;
appliance1_extra_entity?: string;
appliance2_consumption_entity?: string;
appliance2_extra_entity?: string;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
}

View File

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

View File

@ -10,6 +10,7 @@ const ALWAYS_LOADED_LAYOUTS = new Set(["masonry"]);
const LAZY_LOAD_LAYOUTS = {
panel: () => import("../views/hui-panel-view"),
sidebar: () => import("../views/hui-sidebar-view"),
};
export const createViewElement = (

View File

@ -1,6 +1,5 @@
import { LovelaceConfig, LovelaceViewConfig } from "../../../data/lovelace";
import { AsyncReturnType, HomeAssistant } from "../../../types";
import { OriginalStatesStrategy } from "./original-states-strategy";
const MAX_WAIT_STRATEGY_LOAD = 5000;
const CUSTOM_PREFIX = "custom:";
@ -24,9 +23,12 @@ export interface LovelaceViewStrategy {
const strategies: Record<
string,
LovelaceDashboardStrategy & LovelaceViewStrategy
() => Promise<LovelaceDashboardStrategy | LovelaceViewStrategy>
> = {
"original-states": OriginalStatesStrategy,
"original-states": async () =>
(await import("./original-states-strategy")).OriginalStatesStrategy,
energy: async () =>
(await import("../../energy/strategies/energy-strategy")).EnergyStrategy,
};
const getLovelaceStrategy = async <
@ -35,7 +37,7 @@ const getLovelaceStrategy = async <
strategyType: string
): Promise<T> => {
if (strategyType in strategies) {
return strategies[strategyType] as T;
return (await strategies[strategyType]()) as T;
}
if (!strategyType.startsWith(CUSTOM_PREFIX)) {

View File

@ -9,6 +9,7 @@ import {
} from "lit";
import { property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeRTL } from "../../../common/util/compute_rtl";
import type {
LovelaceViewConfig,
@ -18,7 +19,6 @@ import type { HomeAssistant } from "../../../types";
import { HuiErrorCard } from "../cards/hui-error-card";
import { HuiCardOptions } from "../components/hui-card-options";
import { HuiWarning } from "../components/hui-warning";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import type { Lovelace, LovelaceCard } from "../types";
let editCodeLoaded = false;
@ -61,7 +61,8 @@ export class PanelView extends LitElement implements LovelaceViewElement {
| undefined;
if (
oldLovelace?.config !== this.lovelace?.config ||
(!changedProperties.has("cards") &&
oldLovelace?.config !== this.lovelace?.config) ||
(oldLovelace && oldLovelace?.editMode !== this.lovelace?.editMode)
) {
this._createCard();
@ -91,11 +92,7 @@ export class PanelView extends LitElement implements LovelaceViewElement {
}
private _addCard(): void {
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig,
path: [this.index!],
});
fireEvent(this, "ll-create-card");
}
private _createCard(): void {

View File

@ -0,0 +1,218 @@
import { mdiPlus } from "@mdi/js";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeRTL } from "../../../common/util/compute_rtl";
import type {
LovelaceViewConfig,
LovelaceViewElement,
} from "../../../data/lovelace";
import type { HomeAssistant } from "../../../types";
import { HuiErrorCard } from "../cards/hui-error-card";
import type { Lovelace, LovelaceCard } from "../types";
let editCodeLoaded = false;
export class SideBarView extends LitElement implements LovelaceViewElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ type: Number }) public index?: number;
@property({ type: Boolean }) public isStrategy = false;
@property({ attribute: false }) public cards: Array<
LovelaceCard | HuiErrorCard
> = [];
@state() private _config?: LovelaceViewConfig;
public setConfig(config: LovelaceViewConfig): void {
this._config = config;
}
public willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (this.lovelace?.editMode && !editCodeLoaded) {
editCodeLoaded = true;
import("./default-view-editable");
}
if (changedProperties.has("cards")) {
this._createCards();
}
if (!changedProperties.has("lovelace")) {
return;
}
const oldLovelace = changedProperties.get("lovelace") as
| Lovelace
| undefined;
if (
(!changedProperties.has("cards") &&
oldLovelace?.config !== this.lovelace?.config) ||
(oldLovelace && oldLovelace?.editMode !== this.lovelace?.editMode)
) {
this._createCards();
}
}
protected render(): TemplateResult {
return html`
${this.lovelace?.editMode && this.cards.length === 0
? html`
<ha-fab
.label=${this.hass!.localize(
"ui.panel.lovelace.editor.edit_card.add"
)}
extended
@click=${this._addCard}
class=${classMap({
rtl: computeRTL(this.hass!),
})}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
`
: ""}
`;
}
private _addCard(): void {
fireEvent(this, "ll-create-card");
}
private _createCards(): void {
const mainDiv = document.createElement("div");
mainDiv.id = "main";
const sidebarDiv = document.createElement("div");
sidebarDiv.id = "sidebar";
if (this.hasUpdated) {
const oldMain = this.renderRoot.querySelector("#main");
const oldSidebar = this.renderRoot.querySelector("#sidebar");
if (oldMain) {
this.renderRoot.removeChild(oldMain);
}
if (oldSidebar) {
this.renderRoot.removeChild(oldSidebar);
}
this.renderRoot.appendChild(mainDiv);
this.renderRoot.appendChild(sidebarDiv);
} else {
this.updateComplete.then(() => {
this.renderRoot.appendChild(mainDiv);
this.renderRoot.appendChild(sidebarDiv);
});
}
this.cards.forEach((card: LovelaceCard, idx) => {
const cardConfig = this._config?.cards?.[idx];
if (this.isStrategy || !this.lovelace?.editMode) {
card.editMode = false;
if (cardConfig?.view_layout?.position !== "sidebar") {
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 {
sidebarDiv.appendChild(card);
}
});
}
static get styles(): CSSResultGroup {
return css`
:host {
display: flex;
padding-top: 4px;
margin-left: 4px;
margin-right: 4px;
height: 100%;
box-sizing: border-box;
justify-content: center;
}
#main {
max-width: 1620px;
flex-grow: 2;
}
#sidebar {
flex-grow: 1;
max-width: 380px;
}
:host > div {
min-width: 0;
box-sizing: border-box;
}
:host > div > * {
display: block;
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) {
:host > div > * {
margin-left: 0;
margin-right: 0;
}
}
ha-fab {
position: sticky;
float: right;
right: calc(16px + env(safe-area-inset-right));
bottom: calc(16px + env(safe-area-inset-bottom));
z-index: 1;
}
ha-fab.rtl {
float: left;
right: auto;
left: calc(16px + env(safe-area-inset-left));
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-sidebar-view": SideBarView;
}
}
customElements.define("hui-sidebar-view", SideBarView);

View File

@ -10,6 +10,8 @@ import {
Tooltip,
CategoryScale,
Chart,
BarElement,
BarController,
} from "chart.js";
import { TextBarElement } from "../components/chart/timeline-chart/textbar-element";
import { TimelineController } from "../components/chart/timeline-chart/timeline-controller";
@ -26,6 +28,8 @@ Chart.register(
TimeScale,
LinearScale,
LineController,
BarController,
BarElement,
PointElement,
LineElement,
TextBarElement,

View File

@ -151,6 +151,9 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
integration,
configFlow
),
loadFragmentTranslation: (fragment) =>
// @ts-ignore
this._loadFragmentTranslations(this.hass?.language, fragment),
...getState(),
...this._pendingHass,
};

View File

@ -276,8 +276,9 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
panelUrl: string
) {
if (!panelUrl) {
return;
return undefined;
}
const panelComponent = this.hass?.panels?.[panelUrl]?.component_name;
// If it's the first call we don't have panel info yet to check the component.
@ -288,15 +289,16 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
: undefined;
if (!fragment) {
return;
return undefined;
}
if (this.__loadedFragmetTranslations.has(fragment)) {
return;
return this.hass!.localize;
}
this.__loadedFragmetTranslations.add(fragment);
const result = await getTranslation(fragment, language);
await this._updateResources(result.language, result.data);
return this.hass!.localize;
}
private async _loadCoreTranslations(language: string) {

View File

@ -1,5 +1,6 @@
{
"panel": {
"energy": "Energy",
"calendar": "Calendar",
"config": "Configuration",
"states": "Overview",
@ -449,6 +450,16 @@
"loading_history": "Loading state history...",
"no_history_found": "No state history found."
},
"statistics_charts": {
"loading_statistics": "Loading statistics...",
"no_statistics_found": "No statistics found.",
"statistic_types": {
"min": "min",
"max": "max",
"mean": "mean",
"sum": "sum"
}
},
"service-picker": {
"service": "Service"
},
@ -586,6 +597,7 @@
"person": "[%key:ui::panel::config::person::caption%]",
"devices": "[%key:ui::panel::config::devices::caption%]",
"entities": "[%key:ui::panel::config::entities::caption%]",
"energy": "[%key:ui::panel::config::energy::caption%]",
"lovelace": "[%key:ui::panel::config::lovelace::caption%]",
"core": "[%key:ui::panel::config::core::caption%]",
"zone": "[%key:ui::panel::config::zone::caption%]",
@ -975,6 +987,55 @@
"companion_apps": "companion apps"
}
},
"energy": {
"caption": "Energy",
"description": "Monitor your energy production and consumption",
"currency": "",
"grid": {
"title": "Configure 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.",
"flow_dialog": {
"from": {
"header": "Configure grid consumption",
"paragraph": "Grid consumption is the energy that flows from the energy grid to your home.",
"energy_stat": "Consumed Energy (kWh)",
"cost_para": "Select how Home Assistant should keep track of the costs of the consumed energy.",
"no_cost": "Do not track costs",
"cost_stat": "Use an entity tracking the total costs",
"cost_stat_input": "Entity keeping track of the total costs",
"cost_entity": "Use an entity with current price",
"cost_entity_input": "Entity with the current price",
"cost_number": "Use a static price",
"cost_number_input": "Price per kWh",
"cost_number_suffix": "{currency}/kWh"
},
"to": {
"header": "Configure grid production",
"paragraph": "Grid production is the energy that flows from your solar panels to the grid.",
"energy_stat": "Energy returned to the grid (kWh)",
"cost_para": "Do you get money back when you return energy to the grid?",
"no_cost": "I do not get money back",
"cost_stat": "Use an entity tracking the total recieved money",
"cost_stat_input": "Entity keeping track of the total of received money",
"cost_entity": "Use an entity with current rate",
"cost_entity_input": "Entity with the current rate",
"cost_number": "Use a static rate",
"cost_number_input": "Rate per kWh",
"cost_number_suffix": "{currency}/kWh"
}
}
},
"solar": {
"stat_production": "Your solar energy production",
"stat_return_to_grid": "Solar energy returned to the grid",
"stat_predicted_production": "Prediction of your solar energy production"
},
"device_consumption": {
"description": "If you measure the power consumption of individual devices, you can select the entities with the power consumption below",
"add_stat": "Pick entity to track energy of",
"selected_stat": "Tracking energy for"
}
},
"helpers": {
"caption": "Helpers",
"description": "Elements that help build automations",
@ -3621,6 +3682,20 @@
"complete_access": "It will have access to all data in Home Assistant.",
"hide_message": "Check docs for the panel_custom component to hide this message"
}
},
"energy": {
"setup": {
"header": "Setup your energy dashboard",
"slogan": "The world is heating up. Together we can fix that.",
"next": "Next",
"back": "Back",
"done": "Show me my energy dashboard!"
},
"charts": {
"stat_house_energy_meter": "Total energy consumption",
"solar": "Solar",
"by_device": "Consumption by device"
}
}
}
},

View File

@ -239,6 +239,7 @@ export interface HomeAssistant {
integration?: Parameters<typeof getHassTranslations>[3],
configFlow?: Parameters<typeof getHassTranslations>[4]
): Promise<LocalizeFunc>;
loadFragmentTranslation(fragment: string): Promise<LocalizeFunc | undefined>;
}
export interface Route {

100
yarn.lock
View File

@ -1966,6 +1966,32 @@ __metadata:
languageName: node
linkType: hard
"@material/data-table@npm:=12.0.0-canary.1a8d06483.0":
version: 12.0.0-canary.1a8d06483.0
resolution: "@material/data-table@npm:12.0.0-canary.1a8d06483.0"
dependencies:
"@material/animation": 12.0.0-canary.1a8d06483.0
"@material/base": 12.0.0-canary.1a8d06483.0
"@material/checkbox": 12.0.0-canary.1a8d06483.0
"@material/density": 12.0.0-canary.1a8d06483.0
"@material/dom": 12.0.0-canary.1a8d06483.0
"@material/elevation": 12.0.0-canary.1a8d06483.0
"@material/feature-targeting": 12.0.0-canary.1a8d06483.0
"@material/icon-button": 12.0.0-canary.1a8d06483.0
"@material/linear-progress": 12.0.0-canary.1a8d06483.0
"@material/list": 12.0.0-canary.1a8d06483.0
"@material/menu": 12.0.0-canary.1a8d06483.0
"@material/rtl": 12.0.0-canary.1a8d06483.0
"@material/select": 12.0.0-canary.1a8d06483.0
"@material/shape": 12.0.0-canary.1a8d06483.0
"@material/theme": 12.0.0-canary.1a8d06483.0
"@material/touch-target": 12.0.0-canary.1a8d06483.0
"@material/typography": 12.0.0-canary.1a8d06483.0
tslib: ^2.1.0
checksum: 61a5abbc681742a1cd259fa52bda067324313658d438cbef86ee35c6a6858ffb41fa082d592fa8c8390104507e1c2629d0e21f7552598ac4ad2d8d3a00c768cb
languageName: node
linkType: hard
"@material/density@npm:12.0.0-canary.1a8d06483.0":
version: 12.0.0-canary.1a8d06483.0
resolution: "@material/density@npm:12.0.0-canary.1a8d06483.0"
@ -2029,6 +2055,22 @@ __metadata:
languageName: node
linkType: hard
"@material/floating-label@npm:12.0.0-canary.1a8d06483.0":
version: 12.0.0-canary.1a8d06483.0
resolution: "@material/floating-label@npm:12.0.0-canary.1a8d06483.0"
dependencies:
"@material/animation": 12.0.0-canary.1a8d06483.0
"@material/base": 12.0.0-canary.1a8d06483.0
"@material/dom": 12.0.0-canary.1a8d06483.0
"@material/feature-targeting": 12.0.0-canary.1a8d06483.0
"@material/rtl": 12.0.0-canary.1a8d06483.0
"@material/theme": 12.0.0-canary.1a8d06483.0
"@material/typography": 12.0.0-canary.1a8d06483.0
tslib: ^2.1.0
checksum: 49a926db76396dbe5b4b00e4526982afdc059617eaad7abbeee7a48fb05b19924feb512a5b1584e0e3a588fe9b2e572f7da60010810f2a72c8ab51ef29904344
languageName: node
linkType: hard
"@material/form-field@npm:=12.0.0-canary.1a8d06483.0":
version: 12.0.0-canary.1a8d06483.0
resolution: "@material/form-field@npm:12.0.0-canary.1a8d06483.0"
@ -2059,7 +2101,20 @@ __metadata:
languageName: node
linkType: hard
"@material/linear-progress@npm:=12.0.0-canary.1a8d06483.0":
"@material/line-ripple@npm:12.0.0-canary.1a8d06483.0":
version: 12.0.0-canary.1a8d06483.0
resolution: "@material/line-ripple@npm:12.0.0-canary.1a8d06483.0"
dependencies:
"@material/animation": 12.0.0-canary.1a8d06483.0
"@material/base": 12.0.0-canary.1a8d06483.0
"@material/feature-targeting": 12.0.0-canary.1a8d06483.0
"@material/theme": 12.0.0-canary.1a8d06483.0
tslib: ^2.1.0
checksum: e5a7d102819c5d23089afc3d9e85c771ed9f9b7eddce2a0339bb4ed37f3d0d10c36f90370855f9bef2f6e66d421ec082f732f7d930b503779615072299468002
languageName: node
linkType: hard
"@material/linear-progress@npm:12.0.0-canary.1a8d06483.0, @material/linear-progress@npm:=12.0.0-canary.1a8d06483.0":
version: 12.0.0-canary.1a8d06483.0
resolution: "@material/linear-progress@npm:12.0.0-canary.1a8d06483.0"
dependencies:
@ -2108,7 +2163,7 @@ __metadata:
languageName: node
linkType: hard
"@material/menu@npm:=12.0.0-canary.1a8d06483.0":
"@material/menu@npm:12.0.0-canary.1a8d06483.0, @material/menu@npm:=12.0.0-canary.1a8d06483.0":
version: 12.0.0-canary.1a8d06483.0
resolution: "@material/menu@npm:12.0.0-canary.1a8d06483.0"
dependencies:
@ -2385,6 +2440,21 @@ __metadata:
languageName: node
linkType: hard
"@material/notched-outline@npm:12.0.0-canary.1a8d06483.0":
version: 12.0.0-canary.1a8d06483.0
resolution: "@material/notched-outline@npm:12.0.0-canary.1a8d06483.0"
dependencies:
"@material/base": 12.0.0-canary.1a8d06483.0
"@material/feature-targeting": 12.0.0-canary.1a8d06483.0
"@material/floating-label": 12.0.0-canary.1a8d06483.0
"@material/rtl": 12.0.0-canary.1a8d06483.0
"@material/shape": 12.0.0-canary.1a8d06483.0
"@material/theme": 12.0.0-canary.1a8d06483.0
tslib: ^2.1.0
checksum: d0856c5aeba272df09c5c76e99cc630c1614458a8184959fb4a82d54908db5632f09519080831290d9786e989a92601de432c5a4704b6481664ce3fc22d44150
languageName: node
linkType: hard
"@material/progress-indicator@npm:12.0.0-canary.1a8d06483.0":
version: 12.0.0-canary.1a8d06483.0
resolution: "@material/progress-indicator@npm:12.0.0-canary.1a8d06483.0"
@ -2435,6 +2505,31 @@ __metadata:
languageName: node
linkType: hard
"@material/select@npm:12.0.0-canary.1a8d06483.0":
version: 12.0.0-canary.1a8d06483.0
resolution: "@material/select@npm:12.0.0-canary.1a8d06483.0"
dependencies:
"@material/animation": 12.0.0-canary.1a8d06483.0
"@material/base": 12.0.0-canary.1a8d06483.0
"@material/density": 12.0.0-canary.1a8d06483.0
"@material/dom": 12.0.0-canary.1a8d06483.0
"@material/feature-targeting": 12.0.0-canary.1a8d06483.0
"@material/floating-label": 12.0.0-canary.1a8d06483.0
"@material/line-ripple": 12.0.0-canary.1a8d06483.0
"@material/list": 12.0.0-canary.1a8d06483.0
"@material/menu": 12.0.0-canary.1a8d06483.0
"@material/menu-surface": 12.0.0-canary.1a8d06483.0
"@material/notched-outline": 12.0.0-canary.1a8d06483.0
"@material/ripple": 12.0.0-canary.1a8d06483.0
"@material/rtl": 12.0.0-canary.1a8d06483.0
"@material/shape": 12.0.0-canary.1a8d06483.0
"@material/theme": 12.0.0-canary.1a8d06483.0
"@material/typography": 12.0.0-canary.1a8d06483.0
tslib: ^2.1.0
checksum: 6a5483d3497a9ba835df56ca28e059b6f6693d363e6baea67106405ca19485fc517d2824bcb2ef64b94e836a9fb8977552243db9be7be858e7dfb9c1af51d2ab
languageName: node
linkType: hard
"@material/shape@npm:12.0.0-canary.1a8d06483.0, @material/shape@npm:=12.0.0-canary.1a8d06483.0":
version: 12.0.0-canary.1a8d06483.0
resolution: "@material/shape@npm:12.0.0-canary.1a8d06483.0"
@ -8735,6 +8830,7 @@ fsevents@~2.3.1:
"@koa/cors": ^3.1.0
"@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/data-table": =12.0.0-canary.1a8d06483.0
"@material/mwc-button": 0.22.0-canary.cc04657a.0
"@material/mwc-checkbox": 0.22.0-canary.cc04657a.0
"@material/mwc-circular-progress": 0.22.0-canary.cc04657a.0