Add gas to energy dashboard (#9787)

This commit is contained in:
Bram Kragten 2021-08-13 19:39:20 +02:00 committed by GitHub
parent 304bd002ae
commit 3479fb9d94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1090 additions and 16 deletions

View File

@ -49,6 +49,14 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
stat_energy_from: "sensor.battery_output",
stat_energy_to: "sensor.battery_input",
},
{
type: "gas",
stat_energy_from: "sensor.energy_gas",
stat_cost: "sensor.energy_gas_cost",
entity_energy_from: "sensor.energy_gas",
entity_energy_price: null,
number_energy_price: null,
},
],
device_consumption: [
{

View File

@ -104,6 +104,23 @@ export const energyEntities = () =>
unit_of_measurement: "EUR",
},
},
"sensor.energy_gas_cost": {
entity_id: "sensor.energy_gas_cost",
state: "2",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
"sensor.energy_gas": {
entity_id: "sensor.energy_gas",
state: "4",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Gas",
unit_of_measurement: "m³",
},
},
"sensor.energy_car": {
entity_id: "sensor.energy_car",
state: "4",

View File

@ -53,6 +53,14 @@ export const emptyBatteryEnergyPreference =
stat_energy_from: "",
stat_energy_to: "",
});
export const emptyGasEnergyPreference = (): GasSourceTypeEnergyPreference => ({
type: "gas",
stat_energy_from: "",
stat_cost: null,
entity_energy_from: null,
entity_energy_price: null,
number_energy_price: null,
});
export interface DeviceConsumptionEnergyPreference {
// This is an ever increasing value
@ -106,11 +114,26 @@ export interface BatterySourceTypeEnergyPreference {
stat_energy_from: string;
stat_energy_to: string;
}
export interface GasSourceTypeEnergyPreference {
type: "gas";
// 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;
}
type EnergySource =
| SolarSourceTypeEnergyPreference
| GridSourceTypeEnergyPreference
| BatterySourceTypeEnergyPreference;
| BatterySourceTypeEnergyPreference
| GasSourceTypeEnergyPreference;
export interface EnergyPreferences {
energy_sources: EnergySource[];
@ -147,6 +170,7 @@ interface EnergySourceByType {
grid?: GridSourceTypeEnergyPreference[];
solar?: SolarSourceTypeEnergyPreference[];
battery?: BatterySourceTypeEnergyPreference[];
gas?: GasSourceTypeEnergyPreference[];
}
export const energySourcesByType = (prefs: EnergyPreferences) => {
@ -218,6 +242,18 @@ const getEnergyData = async (
continue;
}
if (source.type === "gas") {
statIDs.push(source.stat_energy_from);
if (source.stat_cost) {
statIDs.push(source.stat_cost);
}
const costStatId = info.cost_sensors[source.stat_energy_from];
if (costStatId) {
statIDs.push(costStatId);
}
continue;
}
if (source.type === "battery") {
statIDs.push(source.stat_energy_from);
statIDs.push(source.stat_energy_to);

View File

@ -5,9 +5,7 @@ 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,

View File

@ -0,0 +1,151 @@
import "@material/mwc-button/mwc-button";
import { mdiDelete, mdiFire, mdiPencil } 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/ha-card";
import {
EnergyPreferences,
energySourcesByType,
saveEnergyPreferences,
GasSourceTypeEnergyPreference,
} from "../../../../data/energy";
import {
showConfirmationDialog,
showAlertDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import { showEnergySettingsGasDialog } from "../dialogs/show-dialogs-energy";
import { energyCardStyles } from "./styles";
@customElement("ha-energy-gas-settings")
export class EnergyGasSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public preferences!: EnergyPreferences;
protected render(): TemplateResult {
const types = energySourcesByType(this.preferences);
const gasSources = types.gas || [];
return html`
<ha-card>
<h1 class="card-header">
<ha-svg-icon .path=${mdiFire}></ha-svg-icon>
${this.hass.localize("ui.panel.config.energy.gas.title")}
</h1>
<div class="card-content">
<p>
${this.hass.localize("ui.panel.config.energy.gas.sub")}
<a
target="_blank"
rel="noopener noreferrer"
href="${documentationUrl(this.hass, "/docs/energy/gas/")}"
>${this.hass.localize("ui.panel.config.energy.gas.learn_more")}</a
>
</p>
<h3>Gas consumption</h3>
${gasSources.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=${mdiFire}></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=${mdiFire}></ha-svg-icon>
<mwc-button @click=${this._addSource}>Add gas source</mwc-button>
</div>
</div>
</ha-card>
`;
}
private _addSource() {
showEnergySettingsGasDialog(this, {
saveCallback: async (source) => {
await this._savePreferences({
...this.preferences,
energy_sources: this.preferences.energy_sources.concat(source),
});
},
});
}
private _editSource(ev) {
const origSource: GasSourceTypeEnergyPreference =
ev.currentTarget.closest(".row").source;
showEnergySettingsGasDialog(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: GasSourceTypeEnergyPreference =
ev.currentTarget.closest(".row").source;
if (
!(await showConfirmationDialog(this, {
title: "Are you sure you want 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-gas-settings": EnergyGasSettings;
}
}

View File

@ -10,9 +10,7 @@ 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,

View File

@ -4,9 +4,7 @@ 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,

View File

@ -0,0 +1,277 @@
import { mdiFire } 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 {
emptyGasEnergyPreference,
GasSourceTypeEnergyPreference,
} from "../../../../data/energy";
import { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import { EnergySettingsGasDialogParams } from "./show-dialogs-energy";
import "@material/mwc-button/mwc-button";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/entity/ha-entity-picker";
import "../../../../components/ha-radio";
import "../../../../components/ha-formfield";
import type { HaRadio } from "../../../../components/ha-radio";
const energyUnits = ["m³"];
@customElement("dialog-energy-gas-settings")
export class DialogEnergyGasSettings
extends LitElement
implements HassDialog<EnergySettingsGasDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EnergySettingsGasDialogParams;
@state() private _source?: GasSourceTypeEnergyPreference;
@state() private _costs?: "no-costs" | "number" | "entity" | "statistic";
@state() private _error?: string;
public async showDialog(
params: EnergySettingsGasDialogParams
): Promise<void> {
this._params = params;
this._source = params.source
? { ...params.source }
: (this._source = emptyGasEnergyPreference());
this._costs = this._source.entity_energy_price
? "entity"
: this._source.number_energy_price
? "number"
: this._source.stat_cost
? "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=${mdiFire}
style="--mdc-icon-size: 32px;"
></ha-svg-icon>
Configure Gas consumption`}
@closed=${this.closeDialog}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
<ha-statistic-picker
.hass=${this.hass}
.includeUnitOfMeasurement=${energyUnits}
.value=${this._source.stat_energy_from}
.label=${`Gas usage (m³)`}
entities-only
@value-changed=${this._statisticChanged}
></ha-statistic-picker>
<p>
${this.hass.localize(`ui.panel.config.energy.gas.dialog.cost_para`)}
</p>
<ha-formfield
.label=${this.hass.localize(
`ui.panel.config.energy.gas.dialog.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.gas.dialog.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.stat_cost}
.label=${this.hass.localize(
`ui.panel.config.energy.gas.dialog.cost_stat_input`
)}
@value-changed=${this._priceStatChanged}
></ha-statistic-picker>`
: ""}
<ha-formfield
.label=${this.hass.localize(
`ui.panel.config.energy.gas.dialog.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.gas.dialog.cost_entity_input`
)}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: ""}
<ha-formfield
.label=${this.hass.localize(
`ui.panel.config.energy.gas.dialog.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.gas.dialog.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.gas.dialog.cost_number_suffix`,
{ currency: this.hass.config.currency }
)}</span
>
</paper-input>`
: ""}
<mwc-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass.localize("ui.common.cancel")}
</mwc-button>
<mwc-button
@click=${this._save}
.disabled=${!this._source.stat_energy_from}
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 _numberPriceChanged(ev: CustomEvent) {
this._source = {
...this._source!,
number_energy_price: Number(ev.detail.value),
entity_energy_price: null,
stat_cost: null,
};
}
private _priceStatChanged(ev: CustomEvent) {
this._source = {
...this._source!,
entity_energy_price: null,
number_energy_price: null,
stat_cost: ev.detail.value,
};
}
private _priceEntityChanged(ev: CustomEvent) {
this._source = {
...this._source!,
entity_energy_price: ev.detail.value,
number_energy_price: null,
stat_cost: null,
};
}
private _statisticChanged(ev: CustomEvent<{ value: string }>) {
this._source = {
...this._source!,
stat_energy_from: ev.detail.value,
entity_energy_from: 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._source!.stat_cost = null;
}
await this._params!.saveCallback(this._source!);
this.closeDialog();
} catch (e) {
this._error = e.message;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-max-width: 430px;
}
ha-formfield {
display: block;
}
.price-options {
display: block;
padding-left: 52px;
margin-top: -16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-energy-gas-settings": DialogEnergyGasSettings;
}
}

View File

@ -4,6 +4,7 @@ import {
DeviceConsumptionEnergyPreference,
FlowFromGridSourceEnergyPreference,
FlowToGridSourceEnergyPreference,
GasSourceTypeEnergyPreference,
SolarSourceTypeEnergyPreference,
} from "../../../../data/energy";
@ -39,6 +40,11 @@ export interface EnergySettingsBatteryDialogParams {
saveCallback: (source: BatterySourceTypeEnergyPreference) => Promise<void>;
}
export interface EnergySettingsGasDialogParams {
source?: GasSourceTypeEnergyPreference;
saveCallback: (source: GasSourceTypeEnergyPreference) => Promise<void>;
}
export interface EnergySettingsDeviceDialogParams {
saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise<void>;
}
@ -76,6 +82,17 @@ export const showEnergySettingsSolarDialog = (
});
};
export const showEnergySettingsGasDialog = (
element: HTMLElement,
dialogParams: EnergySettingsGasDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-energy-gas-settings",
dialogImport: () => import("./dialog-energy-gas-settings"),
dialogParams: dialogParams,
});
};
export const showEnergySettingsGridFlowFromDialog = (
element: HTMLElement,
dialogParams: EnergySettingsGridFlowFromDialogParams

View File

@ -11,6 +11,7 @@ import "./components/ha-energy-device-settings";
import "./components/ha-energy-grid-settings";
import "./components/ha-energy-solar-settings";
import "./components/ha-energy-battery-settings";
import "./components/ha-energy-gas-settings";
const INITIAL_CONFIG: EnergyPreferences = {
energy_sources: [],
@ -87,6 +88,11 @@ class HaConfigEnergy extends LitElement {
.preferences=${this._preferences!}
@value-changed=${this._prefsChanged}
></ha-energy-battery-settings>
<ha-energy-gas-settings
.hass=${this.hass}
.preferences=${this._preferences!}
@value-changed=${this._prefsChanged}
></ha-energy-gas-settings>
<ha-energy-device-settings
.hass=${this.hass}
.preferences=${this._preferences!}

View File

@ -9,6 +9,7 @@ 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-battery-settings";
import "../../config/energy/components/ha-energy-gas-settings";
import "../../config/energy/components/ha-energy-device-settings";
import { haStyle } from "../../../resources/styles";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
@ -61,6 +62,12 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
.preferences=${this._preferences}
@value-changed=${this._prefsChanged}
></ha-energy-battery-settings>`
: this._step === 3
? html`<ha-energy-gas-settings
.hass=${this.hass}
.preferences=${this._preferences}
@value-changed=${this._prefsChanged}
></ha-energy-gas-settings>`
: html`<ha-energy-device-settings
.hass=${this.hass}
.preferences=${this._preferences}
@ -72,7 +79,7 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
>${this.hass.localize("ui.panel.energy.setup.back")}</mwc-button
>`
: html`<div></div>`}
${this._step < 3
${this._step < 4
? html`<mwc-button unelevated @click=${this._next}
>${this.hass.localize("ui.panel.energy.setup.next")}</mwc-button
>`
@ -95,7 +102,7 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
}
private _next() {
if (this._step === 2) {
if (this._step === 4) {
return;
}
this._step++;

View File

@ -50,6 +50,7 @@ export class EnergyStrategy {
const hasSolar = prefs.energy_sources.some(
(source) => source.type === "solar"
);
const hasGas = prefs.energy_sources.some((source) => source.type === "gas");
if (info.narrow) {
view.cards!.push({
@ -77,6 +78,15 @@ export class EnergyStrategy {
});
}
// Only include if we have a gas source.
if (hasGas) {
view.cards!.push({
title: "Gas consumption",
type: "energy-gas-graph",
collection_key: "energy_dashboard",
});
}
// Only include if we have a grid.
if (hasGrid) {
view.cards!.push({

View File

@ -4,6 +4,7 @@ import {
mdiArrowRight,
mdiArrowUp,
mdiBatteryHigh,
mdiFire,
mdiHome,
mdiLeaf,
mdiSolarPower,
@ -79,6 +80,7 @@ class HuiEnergyDistrubutionCard
const hasSolarProduction = types.solar !== undefined;
const hasBattery = types.battery !== undefined;
const hasGas = types.gas !== undefined;
const hasReturnToGrid = hasConsumption && types.grid![0].flow_to.length > 0;
const totalFromGrid =
@ -87,6 +89,15 @@ class HuiEnergyDistrubutionCard
types.grid![0].flow_from.map((flow) => flow.stat_energy_from)
) ?? 0;
let gasUsage: number | null = null;
if (hasGas) {
gasUsage =
calculateStatisticsSumGrowth(
this._data.stats,
types.gas!.map((source) => source.stat_energy_from)
) ?? 0;
}
let totalSolarProduction: number | null = null;
if (hasSolarProduction) {
@ -250,7 +261,7 @@ class HuiEnergyDistrubutionCard
return html`
<ha-card .header=${this._config.title}>
<div class="card-content">
${lowCarbonEnergy !== undefined || hasSolarProduction
${lowCarbonEnergy !== undefined || hasSolarProduction || hasGas
? html`<div class="row">
${lowCarbonEnergy === undefined
? html`<div class="spacer"></div>`
@ -287,8 +298,39 @@ class HuiEnergyDistrubutionCard
kWh
</div>
</div>`
: hasGas
? html`<div class="spacer"></div>`
: ""}
<div class="spacer"></div>
${hasGas
? html`<div class="circle-container gas">
<span class="label">Gas</span>
<div class="circle">
<ha-svg-icon .path="${mdiFire}"></ha-svg-icon>
${formatNumber(gasUsage || 0, this.hass.locale, {
maximumFractionDigits: 1,
})}
m³
</div>
<svg width="80" height="30">
<path d="M40 0 v30" id="gas" />
${gasUsage
? svg`<circle
r="1"
class="gas"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="2s"
repeatCount="indefinite"
calcMode="linear"
>
<mpath xlink:href="#gas" />
</animateMotion>
</circle>`
: ""}
</svg>
</div>`
: html`<div class="spacer"></div>`}
</div>`
: ""}
<div class="row">
@ -667,6 +709,10 @@ class HuiEnergyDistrubutionCard
margin-right: 4px;
}
.circle-container.solar {
margin: 0 4px;
height: 130px;
}
.circle-container.gas {
margin-left: 4px;
height: 130px;
}
@ -717,6 +763,17 @@ class HuiEnergyDistrubutionCard
width: 100%;
height: 100%;
}
.gas path,
.gas circle {
stroke: var(--energy-gas-color);
}
circle.gas {
stroke-width: 4;
fill: var(--energy-gas-color);
}
.gas .circle {
border-color: var(--energy-gas-color);
}
.low-carbon line {
stroke: var(--energy-non-fossil-color);
}

View File

@ -0,0 +1,355 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import {
ChartData,
ChartDataset,
ChartOptions,
ScatterDataPoint,
} from "chart.js";
import {
addHours,
differenceInDays,
endOfToday,
isToday,
startOfToday,
} from "date-fns";
import { HomeAssistant } from "../../../../types";
import { LovelaceCard } from "../../types";
import { EnergyGasGraphCardConfig } from "../types";
import {
hex2rgb,
lab2rgb,
rgb2hex,
rgb2lab,
} from "../../../../common/color/convert-color";
import { labDarken } from "../../../../common/color/lab";
import {
EnergyData,
getEnergyDataCollection,
GasSourceTypeEnergyPreference,
} from "../../../../data/energy";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import "../../../../components/chart/ha-chart-base";
import {
formatNumber,
numberFormatToLocale,
} from "../../../../common/string/format_number";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { FrontendLocaleData } from "../../../../data/translation";
import {
reduceSumStatisticsByMonth,
reduceSumStatisticsByDay,
} from "../../../../data/history";
@customElement("hui-energy-gas-graph-card")
export class HuiEnergyGasGraphCard
extends SubscribeMixin(LitElement)
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergyGasGraphCardConfig;
@state() private _chartData: ChartData = {
datasets: [],
};
@state() private _start = startOfToday();
@state() private _end = endOfToday();
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => this._getStatistics(data)),
];
}
public getCardSize(): Promise<number> | number {
return 3;
}
public setConfig(config: EnergyGasGraphCardConfig): void {
this._config = config;
}
protected render(): TemplateResult {
if (!this.hass || !this._config) {
return html``;
}
return html`
<ha-card>
${this._config.title
? html`<h1 class="card-header">${this._config.title}</h1>`
: ""}
<div
class="content ${classMap({
"has-header": !!this._config.title,
})}"
>
<ha-chart-base
.data=${this._chartData}
.options=${this._createOptions(
this._start,
this._end,
this.hass.locale
)}
chart-type="bar"
></ha-chart-base>
${!this._chartData.datasets.length
? html`<div class="no-data">
${isToday(this._start)
? "There is no data to show. It can take up to 2 hours for new data to arrive after you configure your energy dashboard."
: "There is no data for this period."}
</div>`
: ""}
</div>
</ha-card>
`;
}
private _createOptions = memoizeOne(
(start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => {
const dayDifference = differenceInDays(end, start);
return {
parsing: false,
animation: false,
scales: {
x: {
type: "time",
suggestedMin: (dayDifference > 2
? addHours(start, -11)
: start
).getTime(),
suggestedMax: (dayDifference > 2
? addHours(end, -11)
: end
).getTime(),
adapters: {
date: {
locale: locale,
},
},
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat:
dayDifference > 35
? "monthyear"
: dayDifference > 7
? "date"
: dayDifference > 2
? "weekday"
: dayDifference > 0
? "datetime"
: "hour",
minUnit:
dayDifference > 35
? "month"
: dayDifference > 2
? "day"
: "hour",
},
offset: true,
},
y: {
type: "linear",
title: {
display: true,
text: "m³",
},
ticks: {
beginAtZero: true,
},
},
},
plugins: {
tooltip: {
mode: "nearest",
callbacks: {
label: (context) =>
`${context.dataset.label}: ${formatNumber(
context.parsed.y,
locale
)} m³`,
},
},
filler: {
propagate: false,
},
legend: {
display: false,
labels: {
usePointStyle: true,
},
},
},
hover: {
mode: "nearest",
},
elements: {
bar: { borderWidth: 1.5, borderRadius: 4 },
point: {
hitRadius: 5,
},
},
// @ts-expect-error
locale: numberFormatToLocale(locale),
};
}
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
const gasSources: GasSourceTypeEnergyPreference[] =
energyData.prefs.energy_sources.filter(
(source) => source.type === "gas"
) as GasSourceTypeEnergyPreference[];
const statisticsData = Object.values(energyData.stats);
const datasets: ChartDataset<"bar">[] = [];
let endTime: Date;
endTime = new Date(
Math.max(
...statisticsData.map((stats) =>
stats.length ? new Date(stats[stats.length - 1].start).getTime() : 0
)
)
);
if (!endTime || endTime > new Date()) {
endTime = new Date();
}
const computedStyles = getComputedStyle(this);
const gasColor = computedStyles
.getPropertyValue("--energy-gas-color")
.trim();
const dayDifference = differenceInDays(
energyData.end || new Date(),
energyData.start
);
gasSources.forEach((source, idx) => {
const data: ChartDataset<"bar" | "line">[] = [];
const entity = this.hass.states[source.stat_energy_from];
const borderColor =
idx > 0
? rgb2hex(lab2rgb(labDarken(rgb2lab(hex2rgb(gasColor)), idx)))
: gasColor;
let prevValue: number | null = null;
let prevStart: string | null = null;
const gasConsumptionData: ScatterDataPoint[] = [];
// Process gas consumption data.
if (source.stat_energy_from in energyData.stats) {
const stats =
dayDifference > 35
? reduceSumStatisticsByMonth(
energyData.stats[source.stat_energy_from]
)
: dayDifference > 2
? reduceSumStatisticsByDay(
energyData.stats[source.stat_energy_from]
)
: energyData.stats[source.stat_energy_from];
for (const point of stats) {
if (point.sum === null) {
continue;
}
if (prevValue === null) {
prevValue = point.sum;
continue;
}
if (prevStart === point.start) {
continue;
}
const value = point.sum - prevValue;
const date = new Date(point.start);
gasConsumptionData.push({
x: date.getTime(),
y: value,
});
prevStart = point.start;
prevValue = point.sum;
}
}
if (gasConsumptionData.length) {
data.push({
label: entity ? computeStateName(entity) : source.stat_energy_from,
borderColor,
backgroundColor: borderColor + "7F",
data: gasConsumptionData,
});
}
// Concat two arrays
Array.prototype.push.apply(datasets, data);
});
this._start = energyData.start;
this._end = energyData.end || endOfToday();
this._chartData = {
datasets,
};
}
static get styles(): CSSResultGroup {
return css`
ha-card {
height: 100%;
}
.card-header {
padding-bottom: 0;
}
.content {
padding: 16px;
}
.has-header {
padding-top: 0;
}
.no-data {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
padding: 20%;
margin-left: 32px;
box-sizing: border-box;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-gas-graph-card": HuiEnergyGasGraphCard;
}
}

View File

@ -72,9 +72,11 @@ export class HuiEnergySourcesTableCard
}
let totalGrid = 0;
let totalGridCost = 0;
let totalSolar = 0;
let totalBattery = 0;
let totalCost = 0;
let totalGas = 0;
let totalGasCost = 0;
const types = energySourcesByType(this._data.prefs);
@ -94,6 +96,9 @@ export class HuiEnergySourcesTableCard
const consumptionColor = computedStyles
.getPropertyValue("--energy-grid-consumption-color")
.trim();
const gasColor = computedStyles
.getPropertyValue("--energy-gas-color")
.trim();
const showCosts =
types.grid?.[0].flow_from.some(
@ -105,6 +110,10 @@ export class HuiEnergySourcesTableCard
flow.stat_compensation ||
flow.entity_energy_price ||
flow.number_energy_price
) ||
types.gas?.some(
(flow) =>
flow.stat_cost || flow.entity_energy_price || flow.number_energy_price
);
return html` <ha-card>
@ -113,7 +122,7 @@ export class HuiEnergySourcesTableCard
: ""}
<div class="mdc-data-table">
<div class="mdc-data-table__table-container">
<table class="mdc-data-table__table" aria-label="Dessert calories">
<table class="mdc-data-table__table" aria-label="Energy sources">
<thead>
<tr class="mdc-data-table__header-row">
<th class="mdc-data-table__header-cell"></th>
@ -307,7 +316,7 @@ export class HuiEnergySourcesTableCard
) || 0
: null;
if (cost !== null) {
totalCost += cost;
totalGridCost += cost;
}
const color =
idx > 0
@ -367,7 +376,7 @@ export class HuiEnergySourcesTableCard
) || 0) * -1
: null;
if (cost !== null) {
totalCost += cost;
totalGridCost += cost;
}
const color =
idx > 0
@ -421,7 +430,7 @@ export class HuiEnergySourcesTableCard
? html`<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${formatNumber(totalCost, this.hass.locale, {
${formatNumber(totalGridCost, this.hass.locale, {
style: "currency",
currency: this.hass.config.currency!,
})}
@ -429,6 +438,105 @@ export class HuiEnergySourcesTableCard
: ""}
</tr>`
: ""}
${types.gas?.map((source, idx) => {
const entity = this.hass.states[source.stat_energy_from];
const energy =
calculateStatisticSumGrowth(
this._data!.stats[source.stat_energy_from]
) || 0;
totalGas += energy;
const cost_stat =
source.stat_cost ||
this._data!.info.cost_sensors[source.stat_energy_from];
const cost = cost_stat
? calculateStatisticSumGrowth(this._data!.stats[cost_stat]) ||
0
: null;
if (cost !== null) {
totalGasCost += cost;
}
const color =
idx > 0
? rgb2hex(
lab2rgb(labDarken(rgb2lab(hex2rgb(gasColor)), idx))
)
: gasColor;
return html`<tr class="mdc-data-table__row">
<td class="mdc-data-table__cell cell-bullet">
<div
class="bullet"
style=${styleMap({
borderColor: color,
backgroundColor: color + "7F",
})}
></div>
</td>
<th class="mdc-data-table__cell" scope="row">
${entity
? computeStateName(entity)
: source.stat_energy_from}
</th>
<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${formatNumber(energy, this.hass.locale)} m³
</td>
${showCosts
? html`<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${cost !== null
? formatNumber(cost, this.hass.locale, {
style: "currency",
currency: this.hass.config.currency!,
})
: ""}
</td>`
: ""}
</tr>`;
})}
${types.gas
? html`<tr class="mdc-data-table__row total">
<td class="mdc-data-table__cell"></td>
<th class="mdc-data-table__cell" scope="row">Gas total</th>
<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${formatNumber(totalGas, this.hass.locale)} m³
</td>
${showCosts
? html`<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${formatNumber(totalGasCost, this.hass.locale, {
style: "currency",
currency: this.hass.config.currency!,
})}
</td>`
: ""}
</tr>`
: ""}
${totalGasCost && totalGridCost
? html`<tr class="mdc-data-table__row total">
<td class="mdc-data-table__cell"></td>
<th class="mdc-data-table__cell" scope="row">
Total costs
</th>
<td class="mdc-data-table__cell"></td>
<td
class="mdc-data-table__cell mdc-data-table__cell--numeric"
>
${formatNumber(
totalGasCost + totalGridCost,
this.hass.locale,
{
style: "currency",
currency: this.hass.config.currency!,
}
)}
</td>
</tr>`
: ""}
</tbody>
</table>
</div>

View File

@ -269,6 +269,10 @@ export class HuiEnergyUsageGraphCard
continue;
}
if (source.type !== "grid") {
continue;
}
// grid source
for (const flowFrom of source.flow_from) {
if (statistics.from_grid) {

View File

@ -113,6 +113,12 @@ export interface EnergySolarGraphCardConfig extends LovelaceCardConfig {
collection_key?: string;
}
export interface EnergyGasGraphCardConfig extends LovelaceCardConfig {
type: "energy-gas-graph";
title?: string;
collection_key?: string;
}
export interface EnergyDevicesGraphCardConfig extends LovelaceCardConfig {
type: "energy-devices-graph";
title?: string;

View File

@ -39,6 +39,7 @@ const LAZY_LOAD_TYPES = {
import("../cards/energy/hui-energy-usage-graph-card"),
"energy-solar-graph": () =>
import("../cards/energy/hui-energy-solar-graph-card"),
"energy-gas-graph": () => import("../cards/energy/hui-energy-gas-graph-card"),
"energy-devices-graph": () =>
import("../cards/energy/hui-energy-devices-graph-card"),
"energy-sources-table": () =>

View File

@ -90,6 +90,7 @@ documentContainer.innerHTML = `<custom-style>
--energy-non-fossil-color: #0f9d58;
--energy-battery-out-color: #4db6ac;
--energy-battery-in-color: #f06292;
--energy-gas-color: #8E021B;
/* opacity for dark text on a light background */
--dark-divider-opacity: 0.12;

View File

@ -1051,6 +1051,25 @@
"sub": "If you have a battery system, you can configure it to monitor how much energy was stored and used from your battery.",
"learn_more": "More information on how to get started."
},
"gas": {
"title": "Gas Consumption",
"sub": "Let Home Assistant monitor your gas usage.",
"learn_more": "More information on how to get started.",
"dialog": {
"header": "Configure gas consumption",
"paragraph": "Gas consumption is the volume of gas that flows from to your home.",
"energy_stat": "Consumed Energy (m³)",
"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": "Total Costs Entity",
"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 m³",
"cost_number_suffix": "{currency}/m³"
}
},
"device_consumption": {
"title": "Individual devices",
"sub": "Tracking the energy usage of individual devices allows Home Assistant to break down your energy usage by device.",