ha-frontend-cdce8p/src/panels/lovelace/cards/energy/hui-energy-distribution-car...

895 lines
28 KiB
TypeScript

import {
mdiArrowDown,
mdiArrowLeft,
mdiArrowRight,
mdiArrowUp,
mdiBatteryHigh,
mdiFire,
mdiHome,
mdiLeaf,
mdiSolarPower,
mdiTransmissionTower,
} from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "@material/mwc-button";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import {
EnergyData,
energySourcesByType,
getEnergyDataCollection,
getEnergyGasUnit,
} from "../../../../data/energy";
import {
calculateStatisticsSumGrowth,
calculateStatisticsSumGrowthWithPercentage,
} from "../../../../data/history";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { HomeAssistant } from "../../../../types";
import { LovelaceCard } from "../../types";
import { EnergyDistributionCardConfig } from "../types";
const CIRCLE_CIRCUMFERENCE = 238.76104;
@customElement("hui-energy-distribution-card")
class HuiEnergyDistrubutionCard
extends SubscribeMixin(LitElement)
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergyDistributionCardConfig;
@state() private _data?: EnergyData;
public setConfig(config: EnergyDistributionCardConfig): void {
this._config = config;
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => {
this._data = data;
}),
];
}
public getCardSize(): Promise<number> | number {
return 3;
}
protected render() {
if (!this._config) {
return html``;
}
if (!this._data) {
return html`Loading…`;
}
const prefs = this._data.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 hasBattery = types.battery !== undefined;
const hasGas = types.gas !== undefined;
const hasReturnToGrid = hasConsumption && types.grid![0].flow_to.length > 0;
const totalFromGrid =
calculateStatisticsSumGrowth(
this._data.stats,
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) {
totalSolarProduction =
calculateStatisticsSumGrowth(
this._data.stats,
types.solar!.map((source) => source.stat_energy_from)
) || 0;
}
let totalBatteryIn: number | null = null;
let totalBatteryOut: number | null = null;
if (hasBattery) {
totalBatteryIn =
calculateStatisticsSumGrowth(
this._data.stats,
types.battery!.map((source) => source.stat_energy_to)
) || 0;
totalBatteryOut =
calculateStatisticsSumGrowth(
this._data.stats,
types.battery!.map((source) => source.stat_energy_from)
) || 0;
}
let returnedToGrid: number | null = null;
if (hasReturnToGrid) {
returnedToGrid =
calculateStatisticsSumGrowth(
this._data.stats,
types.grid![0].flow_to.map((flow) => flow.stat_energy_to)
) || 0;
}
let solarConsumption: number | null = null;
if (hasSolarProduction) {
solarConsumption =
(totalSolarProduction || 0) -
(returnedToGrid || 0) -
(totalBatteryIn || 0);
}
let batteryFromGrid: null | number = null;
let batteryToGrid: null | number = null;
if (solarConsumption !== null && solarConsumption < 0) {
// What we returned to the grid and what went in to the battery is more than produced,
// so we have used grid energy to fill the battery
// or returned battery energy to the grid
if (hasBattery) {
batteryFromGrid = solarConsumption * -1;
if (batteryFromGrid > totalFromGrid) {
batteryToGrid = Math.min(0, batteryFromGrid - totalFromGrid);
batteryFromGrid = totalFromGrid;
}
}
solarConsumption = 0;
}
let solarToBattery: null | number = null;
if (hasSolarProduction && hasBattery) {
if (!batteryToGrid) {
batteryToGrid = Math.max(
0,
(returnedToGrid || 0) -
(totalSolarProduction || 0) -
(totalBatteryIn || 0) -
(batteryFromGrid || 0)
);
}
solarToBattery = totalBatteryIn! - (batteryFromGrid || 0);
} else if (!hasSolarProduction && hasBattery) {
batteryToGrid = returnedToGrid;
}
let batteryConsumption: number | null = null;
if (hasBattery) {
batteryConsumption = (totalBatteryOut || 0) - (batteryToGrid || 0);
}
const gridConsumption = Math.max(0, totalFromGrid - (batteryFromGrid || 0));
const totalHomeConsumption = Math.max(
0,
gridConsumption + (solarConsumption || 0) + (batteryConsumption || 0)
);
let homeSolarCircumference: number | undefined;
if (hasSolarProduction) {
homeSolarCircumference =
CIRCLE_CIRCUMFERENCE * (solarConsumption! / totalHomeConsumption);
}
let homeBatteryCircumference: number | undefined;
if (batteryConsumption) {
homeBatteryCircumference =
CIRCLE_CIRCUMFERENCE * (batteryConsumption / totalHomeConsumption);
}
let lowCarbonEnergy: number | undefined;
let homeLowCarbonCircumference: number | undefined;
let homeHighCarbonCircumference: number | undefined;
// This fallback is used in the demo
let electricityMapUrl = "https://www.electricitymap.org";
if (
this._data.co2SignalEntity &&
this._data.co2SignalEntity in this._data.stats
) {
// Calculate high carbon consumption
const highCarbonEnergy = calculateStatisticsSumGrowthWithPercentage(
this._data.stats[this._data.co2SignalEntity],
types
.grid![0].flow_from.map(
(flow) => this._data!.stats[flow.stat_energy_from]
)
.filter(Boolean)
);
const co2State = this.hass.states[this._data.co2SignalEntity];
if (co2State?.attributes.country_code) {
electricityMapUrl += `/zone/${co2State.attributes.country_code}`;
}
if (highCarbonEnergy !== null) {
lowCarbonEnergy = totalFromGrid - highCarbonEnergy;
let highCarbonConsumption: number;
if (gridConsumption !== totalFromGrid) {
// Only get the part that was used for consumption and not the battery
highCarbonConsumption =
highCarbonEnergy * (gridConsumption / totalFromGrid);
} else {
highCarbonConsumption = highCarbonEnergy;
}
homeHighCarbonCircumference =
CIRCLE_CIRCUMFERENCE * (highCarbonConsumption / totalHomeConsumption);
homeLowCarbonCircumference =
CIRCLE_CIRCUMFERENCE -
(homeSolarCircumference || 0) -
(homeBatteryCircumference || 0) -
homeHighCarbonCircumference;
}
}
const totalLines =
gridConsumption +
(solarConsumption || 0) +
(returnedToGrid ? returnedToGrid - (batteryToGrid || 0) : 0) +
(solarToBattery || 0) +
(batteryConsumption || 0) +
(batteryFromGrid || 0) +
(batteryToGrid || 0);
return html`
<ha-card .header=${this._config.title}>
<div class="card-content">
${lowCarbonEnergy !== undefined || hasSolarProduction || hasGas
? html`<div class="row">
${lowCarbonEnergy === undefined
? html`<div class="spacer"></div>`
: html`<div class="circle-container low-carbon">
<span class="label">Non-fossil</span>
<a
class="circle"
href=${electricityMapUrl}
target="_blank"
rel="noopener no referrer"
>
<ha-svg-icon .path="${mdiLeaf}"></ha-svg-icon>
${lowCarbonEnergy
? formatNumber(lowCarbonEnergy, this.hass.locale, {
maximumFractionDigits: 1,
})
: "-"}
kWh
</a>
<svg width="80" height="30">
<line x1="40" y1="0" x2="40" y2="30"></line>
</svg>
</div>`}
${hasSolarProduction
? html`<div class="circle-container solar">
<span class="label">Solar</span>
<div class="circle">
<ha-svg-icon .path="${mdiSolarPower}"></ha-svg-icon>
${formatNumber(
totalSolarProduction || 0,
this.hass.locale,
{ maximumFractionDigits: 1 }
)}
kWh
</div>
</div>`
: hasGas
? html`<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,
})}
${getEnergyGasUnit(this.hass, prefs) || "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">
<div class="circle-container grid">
<div class="circle">
<ha-svg-icon .path="${mdiTransmissionTower}"></ha-svg-icon>
${returnedToGrid !== null
? html`<span class="return">
<ha-svg-icon
class="small"
.path=${mdiArrowLeft}
></ha-svg-icon
>${formatNumber(returnedToGrid, this.hass.locale, {
maximumFractionDigits: 1,
})}
kWh
</span>`
: ""}
<span class="consumption">
${hasReturnToGrid
? html`<ha-svg-icon
class="small"
.path=${mdiArrowRight}
></ha-svg-icon>`
: ""}${formatNumber(totalFromGrid, this.hass.locale, {
maximumFractionDigits: 1,
})}
kWh
</span>
</div>
<span class="label">Grid</span>
</div>
<div class="circle-container home">
<div
class="circle ${classMap({
border:
homeSolarCircumference === undefined &&
homeLowCarbonCircumference === undefined,
})}"
>
<ha-svg-icon .path="${mdiHome}"></ha-svg-icon>
${formatNumber(totalHomeConsumption, this.hass.locale, {
maximumFractionDigits: 1,
})}
kWh
${homeSolarCircumference !== undefined ||
homeLowCarbonCircumference !== undefined
? html`<svg>
${homeSolarCircumference !== undefined
? svg`<circle
class="solar"
cx="40"
cy="40"
r="38"
stroke-dasharray="${homeSolarCircumference} ${
CIRCLE_CIRCUMFERENCE - homeSolarCircumference
}"
shape-rendering="geometricPrecision"
stroke-dashoffset="-${
CIRCLE_CIRCUMFERENCE - homeSolarCircumference
}"
/>`
: ""}
${homeBatteryCircumference
? svg`<circle
class="battery"
cx="40"
cy="40"
r="38"
stroke-dasharray="${homeBatteryCircumference} ${
CIRCLE_CIRCUMFERENCE - homeBatteryCircumference
}"
stroke-dashoffset="-${
CIRCLE_CIRCUMFERENCE -
homeBatteryCircumference -
(homeSolarCircumference || 0)
}"
shape-rendering="geometricPrecision"
/>`
: ""}
${homeLowCarbonCircumference
? svg`<circle
class="low-carbon"
cx="40"
cy="40"
r="38"
stroke-dasharray="${homeLowCarbonCircumference} ${
CIRCLE_CIRCUMFERENCE - homeLowCarbonCircumference
}"
stroke-dashoffset="-${
CIRCLE_CIRCUMFERENCE -
homeLowCarbonCircumference -
(homeBatteryCircumference || 0) -
(homeSolarCircumference || 0)
}"
shape-rendering="geometricPrecision"
/>`
: ""}
<circle
class="grid"
cx="40"
cy="40"
r="38"
stroke-dasharray="${homeHighCarbonCircumference ??
CIRCLE_CIRCUMFERENCE -
homeSolarCircumference! -
(homeBatteryCircumference ||
0)} ${homeHighCarbonCircumference !== undefined
? CIRCLE_CIRCUMFERENCE - homeHighCarbonCircumference
: homeSolarCircumference! +
(homeBatteryCircumference || 0)}"
stroke-dashoffset="0"
shape-rendering="geometricPrecision"
/>
</svg>`
: ""}
</div>
<span class="label">Home</span>
</div>
</div>
${hasBattery
? html`<div class="row">
<div class="spacer"></div>
<div class="circle-container battery">
<div class="circle">
<ha-svg-icon .path="${mdiBatteryHigh}"></ha-svg-icon>
<span class="battery-in">
<ha-svg-icon
class="small"
.path=${mdiArrowDown}
></ha-svg-icon
>${formatNumber(totalBatteryIn || 0, this.hass.locale, {
maximumFractionDigits: 1,
})}
kWh</span
>
<span class="battery-out">
<ha-svg-icon
class="small"
.path=${mdiArrowUp}
></ha-svg-icon>
${formatNumber(totalBatteryOut || 0, this.hass.locale, {
maximumFractionDigits: 1,
})}
kWh</span
>
</div>
<span class="label">Battery</span>
</div>
<div class="spacer"></div>
</div>`
: ""}
<div class="lines ${classMap({ battery: hasBattery })}">
<svg
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid slice"
>
${hasReturnToGrid && hasSolarProduction
? svg`<path
id="return"
class="return"
d="M${hasBattery ? 45 : 47},0 v15 c0,${
hasBattery ? "35 -10,30 -30,30" : "40 -10,35 -30,35"
} h-20"
vector-effect="non-scaling-stroke"
></path> `
: ""}
${hasSolarProduction
? svg`<path
id="solar"
class="solar"
d="M${hasBattery ? 55 : 53},0 v15 c0,${
hasBattery ? "35 10,30 30,30" : "40 10,35 30,35"
} h20"
vector-effect="non-scaling-stroke"
></path>`
: ""}
${hasBattery
? svg`<path
id="battery-house"
class="battery-house"
d="M55,100 v-15 c0,-35 10,-30 30,-30 h20"
vector-effect="non-scaling-stroke"
></path>
<path
id="battery-grid"
class=${classMap({
"battery-from-grid": Boolean(batteryFromGrid),
"battery-to-grid": Boolean(batteryToGrid),
})}
d="M45,100 v-15 c0,-35 -10,-30 -30,-30 h-20"
vector-effect="non-scaling-stroke"
></path>
`
: ""}
${hasBattery && hasSolarProduction
? svg`<path
id="battery-solar"
class="battery-solar"
d="M50,0 V100"
vector-effect="non-scaling-stroke"
></path>`
: ""}
<path
class="grid"
id="grid"
d="M0,${hasBattery ? 50 : hasSolarProduction ? 56 : 53} H100"
vector-effect="non-scaling-stroke"
></path>
${returnedToGrid && hasSolarProduction
? svg`<circle
r="1"
class="return"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${
6 -
((returnedToGrid - (batteryToGrid || 0)) / totalLines) *
6
}s"
repeatCount="indefinite"
calcMode="linear"
>
<mpath xlink:href="#return" />
</animateMotion>
</circle>`
: ""}
${solarConsumption
? svg`<circle
r="1"
class="solar"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${6 - (solarConsumption / totalLines) * 5}s"
repeatCount="indefinite"
calcMode="linear"
>
<mpath xlink:href="#solar" />
</animateMotion>
</circle>`
: ""}
${gridConsumption
? svg`<circle
r="1"
class="grid"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${6 - (gridConsumption / totalLines) * 5}s"
repeatCount="indefinite"
calcMode="linear"
>
<mpath xlink:href="#grid" />
</animateMotion>
</circle>`
: ""}
${solarToBattery
? svg`<circle
r="1"
class="battery-solar"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${6 - (solarToBattery / totalLines) * 5}s"
repeatCount="indefinite"
calcMode="linear"
>
<mpath xlink:href="#battery-solar" />
</animateMotion>
</circle>`
: ""}
${batteryConsumption
? svg`<circle
r="1"
class="battery-house"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${6 - (batteryConsumption / totalLines) * 5}s"
repeatCount="indefinite"
calcMode="linear"
>
<mpath xlink:href="#battery-house" />
</animateMotion>
</circle>`
: ""}
${batteryFromGrid
? svg`<circle
r="1"
class="battery-from-grid"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${6 - (batteryFromGrid / totalLines) * 5}s"
repeatCount="indefinite"
keyPoints="1;0" keyTimes="0;1"
calcMode="linear"
>
<mpath xlink:href="#battery-grid" />
</animateMotion>
</circle>`
: ""}
${batteryToGrid
? svg`<circle
r="1"
class="battery-to-grid"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${6 - (batteryToGrid / totalLines) * 5}s"
repeatCount="indefinite"
calcMode="linear"
>
<mpath xlink:href="#battery-grid" />
</animateMotion>
</circle>`
: ""}
</svg>
</div>
</div>
${this._config.link_dashboard
? html`
<div class="card-actions">
<a href="/energy"
><mwc-button> Go to the energy dashboard </mwc-button></a
>
</div>
`
: ""}
</ha-card>
`;
}
static styles = css`
:host {
--mdc-icon-size: 24px;
}
.card-content {
position: relative;
}
.lines {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 146px;
display: flex;
justify-content: center;
padding: 0 16px 16px;
box-sizing: border-box;
}
.lines.battery {
bottom: 100px;
height: 156px;
}
.lines svg {
width: calc(100% - 160px);
height: 100%;
max-width: 340px;
}
.row {
display: flex;
justify-content: space-between;
max-width: 500px;
margin: 0 auto;
}
.circle-container {
display: flex;
flex-direction: column;
align-items: center;
}
.circle-container.low-carbon {
margin-right: 4px;
}
.circle-container.solar {
margin: 0 4px;
height: 130px;
}
.circle-container.gas {
margin-left: 4px;
height: 130px;
}
.circle-container.battery {
height: 110px;
justify-content: flex-end;
}
.spacer {
width: 84px;
}
.circle {
width: 80px;
height: 80px;
border-radius: 50%;
box-sizing: border-box;
border: 2px solid;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-size: 12px;
line-height: 12px;
position: relative;
text-decoration: none;
color: var(--primary-text-color);
}
ha-svg-icon {
padding-bottom: 2px;
}
ha-svg-icon.small {
--mdc-icon-size: 12px;
}
.label {
color: var(--secondary-text-color);
font-size: 12px;
}
line,
path {
stroke: var(--primary-text-color);
stroke-width: 1;
fill: none;
}
.circle svg {
position: absolute;
fill: none;
stroke-width: 4px;
width: 100%;
height: 100%;
}
.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);
}
.low-carbon .circle {
border-color: var(--energy-non-fossil-color);
}
.low-carbon ha-svg-icon {
color: var(--energy-non-fossil-color);
}
circle.low-carbon {
stroke: var(--energy-non-fossil-color);
fill: var(--energy-non-fossil-color);
}
.solar .circle {
border-color: var(--energy-solar-color);
}
circle.solar,
path.solar {
stroke: var(--energy-solar-color);
}
circle.solar {
stroke-width: 4;
fill: var(--energy-solar-color);
}
.battery .circle {
border-color: var(--energy-battery-in-color);
}
circle.battery,
path.battery {
stroke: var(--energy-battery-out-color);
}
path.battery-house,
circle.battery-house {
stroke: var(--energy-battery-out-color);
}
circle.battery-house {
stroke-width: 4;
fill: var(--energy-battery-out-color);
}
path.battery-solar,
circle.battery-solar {
stroke: var(--energy-battery-in-color);
}
circle.battery-solar {
stroke-width: 4;
fill: var(--energy-battery-in-color);
}
.battery-in {
color: var(--energy-battery-in-color);
}
.battery-out {
color: var(--energy-battery-out-color);
}
path.battery-from-grid {
stroke: var(--energy-grid-consumption-color);
}
path.battery-to-grid {
stroke: var(--energy-grid-return-color);
}
path.return,
circle.return,
circle.battery-to-grid {
stroke: var(--energy-grid-return-color);
}
circle.return,
circle.battery-to-grid {
stroke-width: 4;
fill: var(--energy-grid-return-color);
}
.return {
color: var(--energy-grid-return-color);
}
.grid .circle {
border-color: var(--energy-grid-consumption-color);
}
.consumption {
color: var(--energy-grid-consumption-color);
}
circle.grid,
circle.battery-from-grid,
path.grid {
stroke: var(--energy-grid-consumption-color);
}
circle.grid,
circle.battery-from-grid {
stroke-width: 4;
fill: var(--energy-grid-consumption-color);
}
.home .circle {
border-width: 0;
border-color: var(--primary-color);
}
.home .circle.border {
border-width: 2px;
}
.circle svg circle {
animation: rotate-in 0.6s ease-in;
transition: stroke-dashoffset 0.4s, stroke-dasharray 0.4s;
fill: none;
}
@keyframes rotate-in {
from {
stroke-dashoffset: 238.76104;
stroke-dasharray: 238.76104;
}
}
.card-actions a {
text-decoration: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-distribution-card": HuiEnergyDistrubutionCard;
}
}