Skip to content

Commit

Permalink
feat(data_generator): Build your own data based on the last state and…
Browse files Browse the repository at this point in the history
… attributes of your entity (RomRider#14)

Fixes RomRider#6
  • Loading branch information
RomRider authored Jan 27, 2021
1 parent 352c016 commit 18284b5
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 52 deletions.
12 changes: 12 additions & 0 deletions .devcontainer/ui-lovelace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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];
})
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: <br/>
`[[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/).
Expand Down
4 changes: 4 additions & 0 deletions src/apexcharts-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
140 changes: 88 additions & 52 deletions src/graphEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,6 @@ export default class GraphEntry {
}

public async _updateHistory(start: Date, end: Date): Promise<boolean> {
this._realStart = start;
this._realEnd = end;

let startHistory = new Date(start);
if (this._config.group_by.func !== 'raw') {
const range = end.getTime() - start.getTime();
Expand All @@ -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();
});
}
}
}

Expand Down Expand Up @@ -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[] = [[]];
Expand Down
1 change: 1 addition & 0 deletions src/types-config-ti.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions src/types-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface ChartCardSeriesExternalConfig {
extend_to_end?: boolean;
unit?: string;
invert?: boolean;
data_generator?: string;
group_by?: {
duration?: string;
func?: GroupByFunc;
Expand Down

0 comments on commit 18284b5

Please sign in to comment.