Add Trend to Entity and Sensor Card

This commit is contained in:
Zack Arnett 2021-10-21 10:56:10 -05:00
parent 05711b4636
commit e3bbe0e93b
6 changed files with 180 additions and 20 deletions

View File

@ -1,3 +1,4 @@
import { mdiArrowDown, mdiArrowUp } from "@mdi/js";
import { import {
css, css,
CSSResultGroup, CSSResultGroup,
@ -7,6 +8,7 @@ import {
TemplateResult, TemplateResult,
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
@ -16,10 +18,12 @@ import { computeStateDomain } from "../../../common/entity/compute_state_domain"
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
import { isValidEntityId } from "../../../common/entity/valid_entity_id"; import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import { formatNumber } from "../../../common/number/format_number"; import { formatNumber } from "../../../common/number/format_number";
import { round } from "../../../common/number/round";
import { iconColorCSS } from "../../../common/style/icon_color_css"; import { iconColorCSS } from "../../../common/style/icon_color_css";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-icon"; import "../../../components/ha-icon";
import { UNAVAILABLE_STATES } from "../../../data/entity"; import { UNAVAILABLE_STATES } from "../../../data/entity";
import { fetchRecent } from "../../../data/history";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { formatAttributeValue } from "../../../util/hass-attributes-util"; import { formatAttributeValue } from "../../../util/hass-attributes-util";
import { computeCardSize } from "../common/compute-card-size"; import { computeCardSize } from "../common/compute-card-size";
@ -66,8 +70,14 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
@state() private _config?: EntityCardConfig; @state() private _config?: EntityCardConfig;
@state() private _lastState?: number;
private _footerElement?: HuiErrorCard | LovelaceHeaderFooter; private _footerElement?: HuiErrorCard | LovelaceHeaderFooter;
private _date?: Date;
private _fetching = false;
public setConfig(config: EntityCardConfig): void { public setConfig(config: EntityCardConfig): void {
if (!config.entity) { if (!config.entity) {
throw new Error("Entity must be specified"); throw new Error("Entity must be specified");
@ -76,7 +86,10 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
throw new Error("Invalid entity"); throw new Error("Invalid entity");
} }
this._config = config; this._config = {
hours_to_show: 24,
...config,
};
if (this._config.footer) { if (this._config.footer) {
this._footerElement = createHeaderFooterElement(this._config.footer); this._footerElement = createHeaderFooterElement(this._config.footer);
@ -115,23 +128,37 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
: !UNAVAILABLE_STATES.includes(stateObj.state); : !UNAVAILABLE_STATES.includes(stateObj.state);
const name = this._config.name || computeStateName(stateObj); const name = this._config.name || computeStateName(stateObj);
const trend = this._lastState
? round((Number(stateObj.state) / this._lastState) * 100, 0)
: undefined;
return html` return html`
<ha-card @click=${this._handleClick} tabindex="0"> <ha-card @click=${this._handleClick} tabindex="0">
<div class="header"> <div class="header">
<div class="name" .title=${name}>${name}</div> <div class="name" .title=${name}>${name}</div>
<div class="icon"> <div class="icon">
<ha-state-icon ${this._config.show_trend && trend
.icon=${this._config.icon} ? html`
.state=${stateObj} <div class="trend ${classMap({ error: trend < 100 })}">
data-domain=${ifDefined( <ha-svg-icon
this._config.state_color || .path=${trend < 100 ? mdiArrowDown : mdiArrowUp}
(domain === "light" && this._config.state_color !== false) ></ha-svg-icon>
? domain ${trend}%
: undefined </div>
)} `
data-state=${stateObj ? computeActiveState(stateObj) : ""} : html`
></ha-state-icon> <ha-icon
.icon=${this._config.icon || stateIcon(stateObj)}
data-domain=${ifDefined(
this._config.state_color ||
(domain === "light" &&
this._config.state_color !== false)
? domain
: undefined
)}
data-state=${stateObj ? computeActiveState(stateObj) : ""}
></ha-icon>
`}
</div> </div>
</div> </div>
<div class="info"> <div class="info">
@ -177,7 +204,11 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
protected updated(changedProps: PropertyValues) { protected updated(changedProps: PropertyValues) {
super.updated(changedProps); super.updated(changedProps);
if (!this._config || !this.hass) { if (
!this._config ||
!this.hass ||
(this._fetching && !changedProps.has("_config"))
) {
return; return;
} }
@ -194,12 +225,46 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
) { ) {
applyThemesOnElement(this, this.hass.themes, this._config!.theme); applyThemesOnElement(this, this.hass.themes, this._config!.theme);
} }
if (changedProps.has("_config")) {
if (!oldConfig || oldConfig.entity !== this._config.entity) {
this._lastState = undefined;
}
this._getStateHistory();
} else if (Date.now() - this._date!.getTime() >= 60000) {
this._getStateHistory();
}
} }
private _handleClick(): void { private _handleClick(): void {
fireEvent(this, "hass-more-info", { entityId: this._config!.entity }); fireEvent(this, "hass-more-info", { entityId: this._config!.entity });
} }
private async _getStateHistory(): Promise<void> {
if (this._fetching) {
return;
}
this._fetching = true;
const now = new Date();
const startTime = new Date(
new Date().setHours(now.getHours() - this._config!.hours_to_show!)
);
const stateHistory = await fetchRecent(
this.hass!,
this._config!.entity,
startTime,
startTime
);
this._lastState = Number(stateHistory[0][0].state);
this._date = now;
this._fetching = false;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
iconColorCSS, iconColorCSS,
@ -234,6 +299,17 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
line-height: 40px; line-height: 40px;
} }
.trend {
font-size: 16px;
color: var(--success-color);
display: flex;
align-items: center;
}
.trend.error {
color: var(--error-color);
}
.info { .info {
padding: 0px 16px 16px; padding: 0px 16px 16px;
margin-top: -4px; margin-top: -4px;

View File

@ -51,6 +51,7 @@ class HuiSensorCard extends HuiEntityCard {
const entityCardConfig: EntityCardConfig = { const entityCardConfig: EntityCardConfig = {
...cardConfig, ...cardConfig,
hours_to_show,
type: "entity", type: "entity",
}; };

View File

@ -40,6 +40,8 @@ export interface EntityCardConfig extends LovelaceCardConfig {
unit?: string; unit?: string;
theme?: string; theme?: string;
state_color?: boolean; state_color?: boolean;
hours_to_show?: number;
show_trend?: boolean;
} }
export interface EntitiesCardEntityConfig extends EntityConfig { export interface EntitiesCardEntityConfig extends EntityConfig {
@ -357,6 +359,7 @@ export interface SensorCardConfig extends LovelaceCardConfig {
detail?: number; detail?: number;
theme?: string; theme?: string;
hours_to_show?: number; hours_to_show?: number;
show_trend?: boolean;
limits?: { limits?: {
min?: number; min?: number;
max?: number; max?: number;

View File

@ -1,7 +1,15 @@
import "@polymer/paper-input/paper-input"; import "@polymer/paper-input/paper-input";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { assert, assign, boolean, object, optional, string } from "superstruct"; import {
assert,
assign,
boolean,
number,
object,
optional,
string,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain"; import { computeDomain } from "../../../../common/entity/compute_domain";
import { domainIcon } from "../../../../common/entity/domain_icon"; import { domainIcon } from "../../../../common/entity/domain_icon";
@ -29,6 +37,8 @@ const cardConfigStruct = assign(
theme: optional(string()), theme: optional(string()),
state_color: optional(boolean()), state_color: optional(boolean()),
footer: optional(headerFooterConfigStructs), footer: optional(headerFooterConfigStructs),
hours_to_show: optional(number()),
show_trend: optional(boolean()),
}) })
); );
@ -74,6 +84,14 @@ export class HuiEntityCardEditor
return this._config!.theme || ""; return this._config!.theme || "";
} }
get _hours_to_show(): number | string {
return this._config!.hours_to_show || "24";
}
get _show_trend(): boolean {
return this._config!.show_trend || false;
}
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.hass || !this._config) { if (!this.hass || !this._config) {
return html``; return html``;
@ -167,6 +185,36 @@ export class HuiEntityCardEditor
</ha-switch> </ha-switch>
</ha-formfield> </ha-formfield>
</div> </div>
<div class="side-by-side">
<ha-formfield
.label=${this.hass.localize(
"ui.panel.lovelace.editor.card.entity.show_trend"
)}
>
<ha-switch
.checked=${this._show_trend}
.configValue=${"show_trend"}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
${this._show_trend
? html`
<paper-input
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.hours_to_show"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
type="number"
.value=${this._hours_to_show}
min="1"
.configValue=${"hours_to_show"}
@value-changed=${this._valueChanged}
></paper-input>
`
: ""}
</div>
</div> </div>
`; `;
} }

View File

@ -4,7 +4,15 @@ import "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox"; import "@polymer/paper-listbox/paper-listbox";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { assert, assign, number, object, optional, string } from "superstruct"; import {
assert,
assign,
boolean,
number,
object,
optional,
string,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain"; import { computeDomain } from "../../../../common/entity/compute_domain";
import { domainIcon } from "../../../../common/entity/domain_icon"; import { domainIcon } from "../../../../common/entity/domain_icon";
@ -31,6 +39,7 @@ const cardConfigStruct = assign(
detail: optional(number()), detail: optional(number()),
theme: optional(string()), theme: optional(string()),
hours_to_show: optional(number()), hours_to_show: optional(number()),
show_trend: optional(boolean()),
}) })
); );
@ -82,6 +91,10 @@ export class HuiSensorCardEditor
return this._config!.hours_to_show || "24"; return this._config!.hours_to_show || "24";
} }
get _show_trend(): boolean {
return this._config!.show_trend || false;
}
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.hass || !this._config) { if (!this.hass || !this._config) {
return html``; return html``;
@ -193,6 +206,17 @@ export class HuiSensorCardEditor
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></paper-input> ></paper-input>
</div> </div>
<ha-formfield
label=${this.hass.localize(
"ui.panel.lovelace.editor.card.sensor.show_trend"
)}
>
<ha-switch
.checked=${this._show_trend}
.configValue=${"show_trend"}
@change=${this._change}
></ha-switch>
</ha-formfield>
</div> </div>
`; `;
} }
@ -202,15 +226,21 @@ export class HuiSensorCardEditor
return; return;
} }
const value = (ev.target! as EditorTarget).checked ? 2 : 1; const target = ev.target! as EditorTarget;
const value =
target.configValue === "detail"
? (ev.target! as EditorTarget).checked
? 2
: 1
: target.checked;
if (this._detail === value) { if (this[`_${target.configValue}`] === value) {
return; return;
} }
this._config = { this._config = {
...this._config, ...this._config,
detail: value, [target.configValue!]: value,
}; };
fireEvent(this, "config-changed", { config: this._config }); fireEvent(this, "config-changed", { config: this._config });

View File

@ -3277,7 +3277,8 @@
}, },
"entity": { "entity": {
"name": "Entity", "name": "Entity",
"description": "The Entity card gives you a quick overview of your entitys state." "description": "The Entity card gives you a quick overview of your entitys state.",
"show_trend": "Show Trend?"
}, },
"button": { "button": {
"name": "Button", "name": "Button",
@ -3415,7 +3416,8 @@
"name": "Sensor", "name": "Sensor",
"show_more_detail": "Show more detail", "show_more_detail": "Show more detail",
"graph_type": "Graph Type", "graph_type": "Graph Type",
"description": "The Sensor card gives you a quick overview of your sensors state with an optional graph to visualize change over time." "description": "The Sensor card gives you a quick overview of your sensors state with an optional graph to visualize change over time.",
"show_trend": "Show Trend?"
}, },
"shopping-list": { "shopping-list": {
"name": "Shopping List", "name": "Shopping List",