From 18284b5d6d6b8ec4598f02926ed6b3cae6fcd2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20W?= Date: Wed, 27 Jan 2021 18:26:50 +0100 Subject: [PATCH] feat(data_generator): Build your own data based on the last state and attributes of your entity (#14) Fixes #6 --- .devcontainer/ui-lovelace.yaml | 12 +++ README.md | 76 ++++++++++++++++++ src/apexcharts-card.ts | 4 + src/graphEntry.ts | 140 +++++++++++++++++++++------------ src/types-config-ti.ts | 1 + src/types-config.ts | 1 + 6 files changed, 182 insertions(+), 52 deletions(-) diff --git a/.devcontainer/ui-lovelace.yaml b/.devcontainer/ui-lovelace.yaml index 08e4b44..ced8c99 100644 --- a/.devcontainer/ui-lovelace.yaml +++ b/.devcontainer/ui-lovelace.yaml @@ -182,3 +182,15 @@ views: group_by: func: avg fill: 'null' + + - type: custom:apexcharts-card + span: + start: day + series: + - entity: sensor.pvpc + data_generator: | + return [...Array(22).keys()].map((hour) => { + const attr = 'price_' + `${hour}`.padStart(2, '0') + 'h'; + const value = entity.attributes[attr]; + return [moment().startOf('day').hours(hour).valueOf(), value]; + }) diff --git a/README.md b/README.md index b45dbec..ac8773a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ However, some things might be broken :grin: - [`group_by` Options](#group_by-options) - [`func` Options](#func-options) - [`span` Options](#span-options) + - [`data_generator` Option](#data_generator-option) - [Apex Charts Options Example](#apex-charts-options-example) - [Layouts](#layouts) - [Known issues](#known-issues) @@ -117,6 +118,7 @@ The card stricly validates all the options available (but not for the `apex_conf | `unit` | string | | v1.0.0 | Override the unit of the sensor | | `group_by` | object | | v1.0.0 | See [group_by](#group_by-options) | | `invert` | boolean | `false` | NEXT_VERSION | Negates the data (`1` -> `-1`). Usefull to display opposites values like network in (standard)/out (inverted) | +| `data_generator` | string | | NEXT_VERSION | See [data_generator](#data_generator-option) | ### `show` Options @@ -211,6 +213,80 @@ Eg: end: day ``` +### `data_generator` Option + +Before we start, to learn javascript, google is your friend or ask for help on the [forum](https://community.home-assistant.io/t/apexcharts-card-a-highly-customizable-graph-card/272877) :slightly_smiling_face: + +`data_generator` is an advanced feature. It enables you to build your own data out of the last state of a sensor. It completely bypasses the history retrieval and caching mecanism. + +You'll need to write a javascript function which returns a `[timestamp, value][]`: +* `timestamp` is the timestamp of the data in ms +* `value` is the value of the data as a number or a float, make sure you parse it if it's a string. + +Inside your javascript code, you'll have access to those variables: +* `entity`: the entity object +* `start` (Date object): the start Date object of the graph currently displayed +* `end` (Date object): the end Date object of the graph currently displayed +* `hass`: the complete `hass` object +* `moment`: the [Moment.JS](https://momentjs.com/) object to help you manipulate time and dates + +Let's take this example: +* My sensor (`sensor.test`) has this state as its last state: + ```yaml + FirstPeak: High + PeakTimes: + - '2021-01-27 03:43:00' + - '2021-01-27 10:24:00' + - '2021-01-27 16:02:00' + - '2021-01-27 22:38:00' + - '2021-01-28 04:21:00' + - '2021-01-28 11:06:00' + - '2021-01-28 16:40:00' + - '2021-01-28 23:18:00' + - '2021-01-29 05:00:00' + - '2021-01-29 11:45:00' + - '2021-01-29 17:19:00' + - '2021-01-29 23:58:00' + - '2021-01-30 05:39:00' + - '2021-01-30 12:25:00' + - '2021-01-30 17:59:00' + PeakHeights: + - 4.99 + - 1.41 + - 4.96 + - 1.33 + - 5.22 + - 1.19 + - 5.15 + - 1.14 + - 5.42 + - 1.01 + - 5.3 + - 0.99 + - 5.57 + - 0.87 + - 5.39 + unit_of_measurement: m + friendly_name: Tides + ``` +* This is data in the future, but I want to display them so I need to build them myself using `data_generator`: + ```yaml + type: custom:apexcharts-card + graph_span: 4d # I have 4 days worth of data in the future in the attributes + span: + start: hour # I want to display from the start of the current hours 4 days into the future + series: + - entity: sensor.test + data_generator: | # This is what builds the data + return entity.attributes.PeakTimes.map((peak, index) => { + return [new Date(peak), entity.attributes.PeakHeights[index]]; + }); + ``` + The result of this function call would be something like:
+ `[[1611718980000, 4.99], [1611743040000, 1.41], [1611763320000, 4.96], ...]` + +* And this is all you need :tada: + ### Apex Charts Options Example This is how you could change some options from ApexCharts as described on the [`Options (Reference)` menu entry](https://apexcharts.com/docs/installation/). diff --git a/src/apexcharts-card.ts b/src/apexcharts-card.ts index 890ed6b..e8d9d7b 100644 --- a/src/apexcharts-card.ts +++ b/src/apexcharts-card.ts @@ -151,6 +151,10 @@ class ChartsCard extends LitElement { if (entityState && this._entities[index] !== entityState) { this._entities[index] = entityState; updated = true; + if (this._graphs && this._graphs[index]) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this._graphs[index]!.hass = this._hass!; + } } }); if (updated) { diff --git a/src/graphEntry.ts b/src/graphEntry.ts index 87f29e9..32de54e 100644 --- a/src/graphEntry.ts +++ b/src/graphEntry.ts @@ -122,9 +122,6 @@ export default class GraphEntry { } public async _updateHistory(start: Date, end: Date): Promise { - this._realStart = start; - this._realEnd = end; - let startHistory = new Date(start); if (this._config.group_by.func !== 'raw') { const range = end.getTime() - start.getTime(); @@ -134,62 +131,70 @@ export default class GraphEntry { if (!this._entityState || this._updating) return false; this._updating = true; this._timeRange = moment.range(startHistory, end); + let history: EntityEntryCache | undefined = undefined; - let skipInitialState = false; + if (this._config.data_generator) { + this._history = this._generateData(start, end); + } else { + this._realStart = start; + this._realEnd = end; - let history = this._cache ? await this._getCache(this._entityID, this._useCompress) : undefined; + let skipInitialState = false; - if (history && history.span === this._graphSpan) { - const currDataIndex = history.data.findIndex((item) => item && new Date(item[0]).getTime() > start.getTime()); - if (currDataIndex !== -1) { - // skip initial state when fetching recent/not-cached data - skipInitialState = true; - } - if (currDataIndex > 4) { - // >4 so that the graph has some more history - history.data = history.data.slice(currDataIndex === 0 ? 0 : currDataIndex - 4); - } else if (currDataIndex === -1) { - // there was no state which could be used in current graph so clearing - history.data = []; - } - } else { - history = undefined; - } - const newHistory = await this._fetchRecent( - // if data in cache, get data from last data's time + 1ms - history && history.data && history.data.length !== 0 && history.data.slice(-1)[0] - ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - new Date(history.data.slice(-1)[0]![0] + 1) - : startHistory, - end, - skipInitialState, - ); - if (newHistory && newHistory[0] && newHistory[0].length > 0) { - const newStateHistory: EntityCachePoints = newHistory[0].map((item) => { - const stateParsed = parseFloat(item.state); - return [new Date(item.last_changed).getTime(), !Number.isNaN(stateParsed) ? stateParsed : null]; - }); - if (history?.data.length) { - history.span = this._graphSpan; - history.last_fetched = new Date(); - history.card_version = pjson.version; - if (history.data.length !== 0) { - history.data.push(...newStateHistory); + history = this._cache ? await this._getCache(this._entityID, this._useCompress) : undefined; + + if (history && history.span === this._graphSpan) { + const currDataIndex = history.data.findIndex((item) => item && new Date(item[0]).getTime() > start.getTime()); + if (currDataIndex !== -1) { + // skip initial state when fetching recent/not-cached data + skipInitialState = true; + } + if (currDataIndex > 4) { + // >4 so that the graph has some more history + history.data = history.data.slice(currDataIndex === 0 ? 0 : currDataIndex - 4); + } else if (currDataIndex === -1) { + // there was no state which could be used in current graph so clearing + history.data = []; } } else { - history = { - span: this._graphSpan, - card_version: pjson.version, - last_fetched: new Date(), - data: newStateHistory, - }; + history = undefined; } - - if (this._cache) { - this._setCache(this._entityID, history, this._useCompress).catch((err) => { - log(err); - localForage.clear(); + const newHistory = await this._fetchRecent( + // if data in cache, get data from last data's time + 1ms + history && history.data && history.data.length !== 0 && history.data.slice(-1)[0] + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + new Date(history.data.slice(-1)[0]![0] + 1) + : startHistory, + end, + skipInitialState, + ); + if (newHistory && newHistory[0] && newHistory[0].length > 0) { + const newStateHistory: EntityCachePoints = newHistory[0].map((item) => { + const stateParsed = parseFloat(item.state); + return [new Date(item.last_changed).getTime(), !Number.isNaN(stateParsed) ? stateParsed : null]; }); + if (history?.data.length) { + history.span = this._graphSpan; + history.last_fetched = new Date(); + history.card_version = pjson.version; + if (history.data.length !== 0) { + history.data.push(...newStateHistory); + } + } else { + history = { + span: this._graphSpan, + card_version: pjson.version, + last_fetched: new Date(), + data: newStateHistory, + }; + } + + if (this._cache) { + this._setCache(this._entityID, history, this._useCompress).catch((err) => { + log(err); + localForage.clear(); + }); + } } } @@ -218,6 +223,37 @@ export default class GraphEntry { return this._hass?.callApi('GET', url); } + private _generateData(start: Date, end: Date): EntityEntryCache { + let data; + try { + data = new Function( + 'entity', + 'start', + 'end', + 'hass', + 'moment', + `'use strict'; ${this._config.data_generator}`, + ).call(this, this._entityState, start, end, this._hass, moment); + } catch (e) { + const funcTrimmed = + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this._config.data_generator!.length <= 100 + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this._config.data_generator!.trim() + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + `${this._config.data_generator!.trim().substring(0, 98)}...`; + e.message = `${e.name}: ${e.message} in '${funcTrimmed}'`; + e.name = 'Error'; + throw e; + } + return { + span: 0, + card_version: pjson.version, + last_fetched: new Date(), + data, + }; + } + private _dataBucketer(): HistoryBuckets { const ranges = Array.from(this._timeRange.reverseBy('milliseconds', { step: this._groupByDurationMs })).reverse(); // const res: EntityCachePoints[] = [[]]; diff --git a/src/types-config-ti.ts b/src/types-config-ti.ts index ac48952..6ac8fa1 100644 --- a/src/types-config-ti.ts +++ b/src/types-config-ti.ts @@ -35,6 +35,7 @@ export const ChartCardSeriesExternalConfig = t.iface([], { "extend_to_end": t.opt("boolean"), "unit": t.opt("string"), "invert": t.opt("boolean"), + "data_generator": t.opt("string"), "group_by": t.opt(t.iface([], { "duration": t.opt("string"), "func": t.opt("GroupByFunc"), diff --git a/src/types-config.ts b/src/types-config.ts index a3a7164..0915ddf 100644 --- a/src/types-config.ts +++ b/src/types-config.ts @@ -29,6 +29,7 @@ export interface ChartCardSeriesExternalConfig { extend_to_end?: boolean; unit?: string; invert?: boolean; + data_generator?: string; group_by?: { duration?: string; func?: GroupByFunc;