mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-16 13:56:35 +00:00
Add Energy panel (#9445)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
parent
faca62b55f
commit
9dd6b3b72d
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
|
2
src/common/number/round.ts
Normal file
2
src/common/number/round.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const round = (value: number, precision = 2): number =>
|
||||
Math.round(value * 10 ** precision) / 10 ** precision;
|
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
@ -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]
|
||||
|
@ -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>) {
|
||||
|
@ -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
|
||||
);
|
||||
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}
|
||||
|
255
src/components/entity/ha-statistic-picker.ts
Normal file
255
src/components/entity/ha-statistic-picker.ts
Normal 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;
|
||||
}
|
||||
}
|
110
src/components/entity/ha-statistics-picker.ts
Normal file
110
src/components/entity/ha-statistics-picker.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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
131
src/data/energy.ts
Normal 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;
|
||||
};
|
10
src/data/forecast_solar.ts
Normal file
10
src/data/forecast_solar.ts
Normal 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",
|
||||
});
|
@ -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
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
},
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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`
|
||||
|
@ -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;
|
||||
|
||||
|
118
src/panels/config/energy/components/ha-energy-device-settings.ts
Normal file
118
src/panels/config/energy/components/ha-energy-device-settings.ts
Normal 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;
|
||||
}
|
||||
}
|
367
src/panels/config/energy/components/ha-energy-grid-settings.ts
Normal file
367
src/panels/config/energy/components/ha-energy-grid-settings.ts
Normal 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;
|
||||
}
|
||||
}
|
149
src/panels/config/energy/components/ha-energy-solar-settings.ts
Normal file
149
src/panels/config/energy/components/ha-energy-solar-settings.ts
Normal 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;
|
||||
}
|
||||
}
|
37
src/panels/config/energy/components/styles.ts
Normal file
37
src/panels/config/energy/components/styles.ts
Normal 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);
|
||||
}
|
||||
`;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
237
src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts
Normal file
237
src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts
Normal 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;
|
||||
}
|
||||
}
|
85
src/panels/config/energy/dialogs/show-dialogs-energy.ts
Normal file
85
src/panels/config/energy/dialogs/show-dialogs-energy.ts
Normal 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" },
|
||||
});
|
||||
};
|
164
src/panels/config/energy/ha-config-energy.ts
Normal file
164
src/panels/config/energy/ha-config-energy.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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"),
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
142
src/panels/energy/cards/energy-setup-wizard-card.ts
Normal file
142
src/panels/energy/cards/energy-setup-wizard-card.ts
Normal 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;
|
||||
}
|
||||
}
|
130
src/panels/energy/ha-panel-energy.ts
Normal file
130
src/panels/energy/ha-panel-energy.ts
Normal 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;
|
||||
}
|
||||
}
|
120
src/panels/energy/strategies/energy-strategy.ts
Normal file
120
src/panels/energy/strategies/energy-strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
252
src/panels/lovelace/cards/hui-energy-costs-table-card.ts
Normal file
252
src/panels/lovelace/cards/hui-energy-costs-table-card.ts
Normal 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;
|
||||
}
|
||||
}
|
254
src/panels/lovelace/cards/hui-energy-devices-graph-card.ts
Normal file
254
src/panels/lovelace/cards/hui-energy-devices-graph-card.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
412
src/panels/lovelace/cards/hui-energy-solar-graph-card.ts
Normal file
412
src/panels/lovelace/cards/hui-energy-solar-graph-card.ts
Normal 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;
|
||||
}
|
||||
}
|
302
src/panels/lovelace/cards/hui-energy-summary-card.ts
Normal file
302
src/panels/lovelace/cards/hui-energy-summary-card.ts
Normal 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;
|
||||
}
|
||||
}
|
440
src/panels/lovelace/cards/hui-energy-summary-graph-card.ts
Normal file
440
src/panels/lovelace/cards/hui-energy-summary-graph-card.ts
Normal 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;
|
||||
}
|
||||
}
|
333
src/panels/lovelace/cards/hui-energy-usage-card.ts
Normal file
333
src/panels/lovelace/cards/hui-energy-usage-card.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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"),
|
||||
|
@ -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 = (
|
||||
|
@ -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)) {
|
||||
|
@ -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 {
|
||||
|
218
src/panels/lovelace/views/hui-sidebar-view.ts
Normal file
218
src/panels/lovelace/views/hui-sidebar-view.ts
Normal 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);
|
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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) {
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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
100
yarn.lock
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user