From 8ae03dd1ffda68babed9329504c11374ae41f3f5 Mon Sep 17 00:00:00 2001 From: Zack Arnett Date: Fri, 30 Nov 2018 22:50:21 -0500 Subject: [PATCH] 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 --- src/panels/lovelace/cards/hui-sensor-card.js | 322 --------------- src/panels/lovelace/cards/hui-sensor-card.ts | 404 +++++++++++++++++++ 2 files changed, 404 insertions(+), 322 deletions(-) delete mode 100755 src/panels/lovelace/cards/hui-sensor-card.js create mode 100755 src/panels/lovelace/cards/hui-sensor-card.ts diff --git a/src/panels/lovelace/cards/hui-sensor-card.js b/src/panels/lovelace/cards/hui-sensor-card.js deleted file mode 100755 index 58a8c96574..0000000000 --- a/src/panels/lovelace/cards/hui-sensor-card.js +++ /dev/null @@ -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()} - -
-
- -
-
- ${this._computeName(_entity)} -
-
-
- ${_entity.state} - ${this._computeUom(_entity)} -
-
-
- ${ - _line - ? svg` - - - - ` - : "" - } -
-
-
- `; - } - - _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` - - `; - } -} - -customElements.define("hui-sensor-card", HuiSensorCard); diff --git a/src/panels/lovelace/cards/hui-sensor-card.ts b/src/panels/lovelace/cards/hui-sensor-card.ts new file mode 100755 index 0000000000..86b2159082 --- /dev/null +++ b/src/panels/lovelace/cards/hui-sensor-card.ts @@ -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` +
+ Entity: ${this._config.entity} - Has no Unit of Measurement and + therefore can not display a line graph. +
+ `; + } else if (!this._history) { + graph = svg` + + `; + } else { + graph = svg` + + + + `; + } + } else { + graph = ""; + } + return html` + ${this.renderStyle()} + + ${ + !stateObj + ? html` +
+ Entity not available: ${this._config.entity} +
+ ` + : html` +
+
+ +
+
+ ${this._config.name || computeStateName(stateObj)} +
+
+
+ ${stateObj.state} + ${ + this._config.unit || + stateObj.attributes.unit_of_measurement + } +
+
${graph}
+ ` + } +
+ `; + } + + 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 { + 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` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-sensor-card": HuiSensorCard; + } +} + +customElements.define("hui-sensor-card", HuiSensorCard);