Convert Sensor Card to Typescript (#2140)

* Sensor Convert

* Types

* Forgot to check stateobj

* Review updates

* lint

* Update for hass and add error handling

* Review Updates

* Graph only shown if graph: line - Breaking Change

* Only rendering when updated

* Date.Now()

* Forgot to reset the date

* Lint

* Review updates

* Forgot to take this out

* Bram the god

* Update to render right things

* Check if line is being drawn

* Make graph if's more readable
This commit is contained in:
Zack Arnett 2018-11-30 22:50:21 -05:00 committed by GitHub
parent 0e6f6ddbda
commit 8ae03dd1ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 404 additions and 322 deletions

View File

@ -1,322 +0,0 @@
import { LitElement, html, svg } from "@polymer/lit-element";
import "../../../components/ha-card";
import "../../../components/ha-icon";
import computeStateName from "../../../common/entity/compute_state_name";
import stateIcon from "../../../common/entity/state_icon";
import EventsMixin from "../../../mixins/events-mixin";
class HuiSensorCard extends EventsMixin(LitElement) {
set hass(hass) {
this._hass = hass;
const entity = hass.states[this._config.entity];
if (entity && this._entity !== entity) {
this._entity = entity;
if (
this._config.graph !== "none" &&
entity.attributes.unit_of_measurement
) {
this._getHistory();
}
}
}
static get properties() {
return {
_hass: {},
_config: {},
_entity: {},
_line: String,
_min: Number,
_max: Number,
};
}
setConfig(config) {
if (!config.entity || config.entity.split(".")[0] !== "sensor") {
throw new Error("Specify an entity from within the sensor domain.");
}
const cardConfig = {
detail: 1,
icon: false,
hours_to_show: 24,
...config,
};
cardConfig.hours_to_show = Number(cardConfig.hours_to_show);
cardConfig.height = Number(cardConfig.height);
cardConfig.detail =
cardConfig.detail === 1 || cardConfig.detail === 2
? cardConfig.detail
: 1;
this._config = cardConfig;
}
shouldUpdate(changedProps) {
const change = changedProps.has("_entity") || changedProps.has("_line");
return change;
}
render({ _entity, _line } = this) {
return html`
${this._style()}
<ha-card @click="${this._handleClick}">
<div class="flex">
<div class="icon">
<ha-icon .icon="${this._computeIcon(_entity)}"></ha-icon>
</div>
<div class="header">
<span class="name">${this._computeName(_entity)}</span>
</div>
</div>
<div class="flex info">
<span id="value">${_entity.state}</span>
<span id="measurement">${this._computeUom(_entity)}</span>
</div>
<div class="graph">
<div>
${
_line
? svg`
<svg width="100%" height="100%" viewBox="0 0 500 100">
<path
d="${_line}"
fill="none"
stroke="var(--accent-color)"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
`
: ""
}
</div>
</div>
</ha-card>
`;
}
_handleClick() {
this.fire("hass-more-info", { entityId: this._config.entity });
}
_computeIcon(item) {
return this._config.icon || stateIcon(item);
}
_computeName(item) {
return this._config.name || computeStateName(item);
}
_computeUom(item) {
return this._config.unit || item.attributes.unit_of_measurement;
}
_coordinates(history, hours, width, detail = 1) {
history = history.filter((item) => !Number.isNaN(Number(item.state)));
this._min = Math.min.apply(Math, history.map((item) => Number(item.state)));
this._max = Math.max.apply(Math, history.map((item) => Number(item.state)));
const now = new Date().getTime();
const reduce = (res, item, min = false) => {
const age = now - new Date(item.last_changed).getTime();
let key = Math.abs(age / (1000 * 3600) - hours);
if (min) {
key = (key - Math.floor(key)) * 60;
key = (Math.round(key / 10) * 10).toString()[0];
} else {
key = Math.floor(key);
}
if (!res[key]) res[key] = [];
res[key].push(item);
return res;
};
history = history.reduce((res, item) => reduce(res, item), []);
if (detail > 1) {
history = history.map((entry) =>
entry.reduce((res, item) => reduce(res, item, true), [])
);
}
return this._calcPoints(history, hours, width, detail);
}
_calcPoints(history, hours, width, detail = 1) {
const coords = [];
const margin = 5;
const height = 80;
width -= margin * 2;
let yRatio = (this._max - this._min) / height;
yRatio = yRatio !== 0 ? yRatio : height;
let xRatio = width / (hours - (detail === 1 ? 1 : 0));
xRatio = isFinite(xRatio) ? xRatio : width;
const getCoords = (item, i, offset = 0, depth = 1) => {
if (depth > 1)
return item.forEach((subItem, index) =>
getCoords(subItem, i, index, depth - 1)
);
const average =
item.reduce((sum, entry) => sum + parseFloat(entry.state), 0) /
item.length;
const x = xRatio * (i + offset / 6) + margin;
const y = height - (average - this._min) / yRatio + margin * 2;
return coords.push([x, y]);
};
history.forEach((item, i) => getCoords(item, i, 0, detail));
if (coords.length === 1) coords[1] = [width + margin, coords[0][1]];
coords.push([width + margin, coords[coords.length - 1][1]]);
return coords;
}
_getPath(coords) {
let next;
let Z;
const X = 0;
const Y = 1;
let path = "";
let last = coords.filter(Boolean)[0];
path += `M ${last[X]},${last[Y]}`;
for (let i = 0; i < coords.length; i++) {
next = coords[i];
Z = this._midPoint(last[X], last[Y], next[X], next[Y]);
path += ` ${Z[X]},${Z[Y]}`;
path += ` Q${next[X]},${next[Y]}`;
last = next;
}
path += ` ${next[X]},${next[Y]}`;
return path;
}
_midPoint(Ax, Ay, Bx, By) {
const Zx = (Ax - Bx) / 2 + Bx;
const Zy = (Ay - By) / 2 + By;
return [Zx, Zy];
}
async _getHistory() {
const endTime = new Date();
const startTime = new Date();
startTime.setHours(endTime.getHours() - this._config.hours_to_show);
const stateHistory = await this._fetchRecent(
this._config.entity,
startTime,
endTime
);
if (stateHistory[0].length < 1) return;
const coords = this._coordinates(
stateHistory[0],
this._config.hours_to_show,
500,
this._config.detail
);
this._line = this._getPath(coords);
}
async _fetchRecent(entityId, startTime, endTime) {
let url = "history/period";
if (startTime) url += "/" + startTime.toISOString();
url += "?filter_entity_id=" + entityId;
if (endTime) url += "&end_time=" + endTime.toISOString();
return await this._hass.callApi("GET", url);
}
getCardSize() {
return 3;
}
_style() {
return html`
<style>
:host {
display: flex;
flex-direction: column;
}
ha-card {
display: flex;
flex-direction: column;
flex: 1;
padding: 16px;
position: relative;
cursor: pointer;
}
.flex {
display: flex;
}
.header {
align-items: center;
display: flex;
min-width: 0;
opacity: 0.8;
position: relative;
}
.name {
display: block;
display: -webkit-box;
font-size: 1.2rem;
font-weight: 500;
max-height: 1.4rem;
margin-top: 2px;
opacity: 0.8;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-wrap: break-word;
word-break: break-all;
}
.icon {
color: var(--paper-item-icon-color, #44739e);
display: inline-block;
flex: 0 0 40px;
line-height: 40px;
position: relative;
text-align: center;
width: 40px;
}
.info {
flex-wrap: wrap;
margin: 16px 0 16px 8px;
}
#value {
display: inline-block;
font-size: 2rem;
font-weight: 400;
line-height: 1em;
margin-right: 4px;
}
#measurement {
align-self: flex-end;
display: inline-block;
font-size: 1.3rem;
line-height: 1.2em;
margin-top: 0.1em;
opacity: 0.6;
vertical-align: bottom;
}
.graph {
align-self: flex-end;
margin: auto;
margin-bottom: 0px;
position: relative;
width: 100%;
}
.graph > div {
align-self: flex-end;
margin: auto 8px;
}
</style>
`;
}
}
customElements.define("hui-sensor-card", HuiSensorCard);

View File

@ -0,0 +1,404 @@
import {
html,
svg,
LitElement,
PropertyDeclarations,
PropertyValues,
} from "@polymer/lit-element";
import { TemplateResult } from "lit-html";
import "@polymer/paper-spinner/paper-spinner";
import { LovelaceCard } from "../types";
import { LovelaceCardConfig } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types";
import { fireEvent } from "../../../common/dom/fire_event";
import computeStateName from "../../../common/entity/compute_state_name";
import stateIcon from "../../../common/entity/state_icon";
import "../../../components/ha-card";
import "../../../components/ha-icon";
import { fetchRecent } from "../../../data/history";
const midPoint = (
_Ax: number,
_Ay: number,
_Bx: number,
_By: number
): number[] => {
const _Zx = (_Ax - _Bx) / 2 + _Bx;
const _Zy = (_Ay - _By) / 2 + _By;
return [_Zx, _Zy];
};
const getPath = (coords: number[][]): string => {
let next;
let Z;
const X = 0;
const Y = 1;
let path = "";
let last = coords.filter(Boolean)[0];
path += `M ${last[X]},${last[Y]}`;
for (const coord of coords) {
next = coord;
Z = midPoint(last[X], last[Y], next[X], next[Y]);
path += ` ${Z[X]},${Z[Y]}`;
path += ` Q${next[X]},${next[Y]}`;
last = next;
}
path += ` ${next[X]},${next[Y]}`;
return path;
};
const calcPoints = (
history: any,
hours: number,
width: number,
detail: number,
min: number,
max: number
): number[][] => {
const coords = [] as number[][];
const margin = 5;
const height = 80;
width -= 10;
let yRatio = (max - min) / height;
yRatio = yRatio !== 0 ? yRatio : height;
let xRatio = width / (hours - (detail === 1 ? 1 : 0));
xRatio = isFinite(xRatio) ? xRatio : width;
const getCoords = (item, i, offset = 0, depth = 1) => {
if (depth > 1) {
return item.forEach((subItem, index) =>
getCoords(subItem, i, index, depth - 1)
);
}
const average =
item.reduce((sum, entry) => sum + parseFloat(entry.state), 0) /
item.length;
const x = xRatio * (i + offset / 6) + margin;
const y = height - (average - min) / yRatio + margin * 2;
return coords.push([x, y]);
};
history.forEach((item, i) => getCoords(item, i, 0, detail));
if (coords.length === 1) {
coords[1] = [width + margin, coords[0][1]];
}
coords.push([width + margin, coords[coords.length - 1][1]]);
return coords;
};
const coordinates = (
history: any,
hours: number,
width: number,
detail: number
): number[][] => {
history.forEach((item) => (item.state = Number(item.state)));
history = history.filter((item) => !Number.isNaN(item.state));
const min = Math.min.apply(Math, history.map((item) => item.state));
const max = Math.max.apply(Math, history.map((item) => item.state));
const now = new Date().getTime();
const reduce = (res, item, point) => {
const age = now - new Date(item.last_changed).getTime();
let key = Math.abs(age / (1000 * 3600) - hours);
if (point) {
key = (key - Math.floor(key)) * 60;
key = Number((Math.round(key / 10) * 10).toString()[0]);
} else {
key = Math.floor(key);
}
if (!res[key]) {
res[key] = [];
}
res[key].push(item);
return res;
};
history = history.reduce((res, item) => reduce(res, item, false), []);
if (detail > 1) {
history = history.map((entry) =>
entry.reduce((res, item) => reduce(res, item, true), [])
);
}
return calcPoints(history, hours, width, detail, min, max);
};
interface Config extends LovelaceCardConfig {
entity: string;
name?: string;
icon?: string;
graph?: string;
unit?: string;
detail?: number;
hours_to_show?: number;
}
class HuiSensorCard extends LitElement implements LovelaceCard {
public hass?: HomeAssistant;
private _config?: Config;
private _history?: any;
private _date?: Date;
static get properties(): PropertyDeclarations {
return {
hass: {},
_config: {},
_history: {},
};
}
public setConfig(config: Config): void {
if (!config.entity || config.entity.split(".")[0] !== "sensor") {
throw new Error("Specify an entity from within the sensor domain.");
}
const cardConfig = {
detail: 1,
hours_to_show: 24,
...config,
};
cardConfig.hours_to_show = Number(cardConfig.hours_to_show);
cardConfig.detail =
cardConfig.detail === 1 || cardConfig.detail === 2
? cardConfig.detail
: 1;
this._config = cardConfig;
}
public getCardSize(): number {
return 3;
}
protected render(): TemplateResult {
if (!this._config || !this.hass) {
return html``;
}
const stateObj = this.hass.states[this._config.entity];
let graph;
if (this._config.graph === "line") {
if (!stateObj.attributes.unit_of_measurement) {
graph = html`
<div class="not-found">
Entity: ${this._config.entity} - Has no Unit of Measurement and
therefore can not display a line graph.
</div>
`;
} else if (!this._history) {
graph = svg`
<svg width="100%" height="100%" viewBox="0 0 500 100"></svg>
`;
} else {
graph = svg`
<svg width="100%" height="100%" viewBox="0 0 500 100">
<path
d="${this._history}"
fill="none"
stroke="var(--accent-color)"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
`;
}
} else {
graph = "";
}
return html`
${this.renderStyle()}
<ha-card @click="${this._handleClick}">
${
!stateObj
? html`
<div class="not-found">
Entity not available: ${this._config.entity}
</div>
`
: html`
<div class="flex">
<div class="icon">
<ha-icon
.icon="${this._config.icon || stateIcon(stateObj)}"
></ha-icon>
</div>
<div class="header">
<span class="name"
>${this._config.name || computeStateName(stateObj)}</span
>
</div>
</div>
<div class="flex info">
<span id="value">${stateObj.state}</span>
<span id="measurement"
>${
this._config.unit ||
stateObj.attributes.unit_of_measurement
}</span
>
</div>
<div class="graph"><div>${graph}</div></div>
`
}
</ha-card>
`;
}
protected firstUpdated(): void {
this._date = new Date();
}
protected updated(changedProps: PropertyValues) {
if (this._config && this._config.graph !== "line") {
return;
}
const minute = 60000;
if (changedProps.has("_config")) {
this._getHistory();
} else if (Date.now() - this._date!.getTime() >= minute) {
this._getHistory();
}
}
private _handleClick(): void {
fireEvent(this, "hass-more-info", { entityId: this._config!.entity });
}
private async _getHistory(): Promise<void> {
const endTime = new Date();
const startTime = new Date();
startTime.setHours(endTime.getHours() - this._config!.hours_to_show!);
const stateHistory = await fetchRecent(
this.hass,
this._config!.entity,
startTime,
endTime
);
if (stateHistory[0].length < 1) {
return;
}
const coords = coordinates(
stateHistory[0],
this._config!.hours_to_show!,
500,
this._config!.detail!
);
this._history = getPath(coords);
this._date = new Date();
}
private renderStyle(): TemplateResult {
return html`
<style>
:host {
display: flex;
flex-direction: column;
}
ha-card {
display: flex;
flex-direction: column;
flex: 1;
padding: 16px;
position: relative;
cursor: pointer;
}
.flex {
display: flex;
}
.header {
align-items: center;
display: flex;
min-width: 0;
opacity: 0.8;
position: relative;
}
.name {
display: block;
display: -webkit-box;
font-size: 1.2rem;
font-weight: 500;
max-height: 1.4rem;
margin-top: 2px;
opacity: 0.8;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-wrap: break-word;
word-break: break-all;
}
.icon {
color: var(--paper-item-icon-color, #44739e);
display: inline-block;
flex: 0 0 40px;
line-height: 40px;
position: relative;
text-align: center;
width: 40px;
}
.info {
flex-wrap: wrap;
margin: 16px 0 16px 8px;
}
#value {
display: inline-block;
font-size: 2rem;
font-weight: 400;
line-height: 1em;
margin-right: 4px;
}
#measurement {
align-self: flex-end;
display: inline-block;
font-size: 1.3rem;
line-height: 1.2em;
margin-top: 0.1em;
opacity: 0.6;
vertical-align: bottom;
}
.graph {
align-self: flex-end;
margin: auto;
margin-bottom: 0px;
position: relative;
width: 100%;
}
.graph > div {
align-self: flex-end;
margin: auto 8px;
}
.not-found {
flex: 1;
background-color: yellow;
padding: 8px;
}
</style>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-sensor-card": HuiSensorCard;
}
}
customElements.define("hui-sensor-card", HuiSensorCard);