Skip to content

Commit 59c04f9

Browse files
authored
Perf/avoid redundant fetches (#144)
* Only fetch statistics once per entity, even if multiple traces show different ones (e.g max, min, ...) * Same for multiple attributes of the same entity
1 parent 64640c2 commit 59c04f9

File tree

5 files changed

+101
-71
lines changed

5 files changed

+101
-71
lines changed

src/cache/Cache.ts

Lines changed: 66 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import {
88
EntityConfig,
99
isEntityIdStateConfig,
1010
isEntityIdStatisticsConfig,
11-
HistoryInRange,
12-
EntityState,
11+
CachedEntity,
12+
CachedStatisticsEntity,
13+
CachedStateEntity,
1314
} from "../types";
15+
import { groupBy } from "lodash";
16+
import { StatisticValue } from "../recorder-types";
1417

1518
export function mapValues<T, S>(
1619
o: Record<string, T>,
@@ -24,7 +27,10 @@ async function fetchSingleRange(
2427
[startT, endT]: number[],
2528
significant_changes_only: boolean,
2629
minimal_response: boolean
27-
): Promise<HistoryInRange> {
30+
): Promise<{
31+
range: [number, number];
32+
history: CachedEntity[];
33+
}> {
2834
// We fetch slightly more than requested (i.e the range visible in the screen). The reason is the following:
2935
// When fetching data in a range `[startT,endT]`, Home Assistant adds a fictitious datapoint at
3036
// the start of the fetched period containing a copy of the first datapoint that occurred before
@@ -65,7 +71,7 @@ async function fetchSingleRange(
6571
const start = new Date(startT - 1);
6672
endT = Math.min(endT, Date.now());
6773
const end = new Date(endT);
68-
let history: EntityState[];
74+
let history: CachedEntity[];
6975
if (isEntityIdStatisticsConfig(entity)) {
7076
history = await fetchStatistics(hass, entity, [start, end]);
7177
} else {
@@ -90,9 +96,9 @@ async function fetchSingleRange(
9096

9197
export function getEntityKey(entity: EntityConfig) {
9298
if (isEntityIdAttrConfig(entity)) {
93-
return `${entity.entity}::${entity.attribute}`;
99+
return `${entity.entity}::attribute`;
94100
} else if (isEntityIdStatisticsConfig(entity)) {
95-
return `${entity.entity}::statistics::${entity.statistic}::${entity.period}`;
101+
return `${entity.entity}::statistics::${entity.period}`;
96102
} else if (isEntityIdStateConfig(entity)) {
97103
return entity.entity;
98104
}
@@ -102,10 +108,10 @@ export function getEntityKey(entity: EntityConfig) {
102108
const MIN_SAFE_TIMESTAMP = Date.parse("0001-01-02T00:00:00.000Z");
103109
export default class Cache {
104110
ranges: Record<string, TimestampRange[]> = {};
105-
histories: Record<string, EntityState[]> = {};
111+
histories: Record<string, CachedEntity[]> = {};
106112
busy = Promise.resolve(); // mutex
107113

108-
add(entity: EntityConfig, states: EntityState[], range: [number, number]) {
114+
add(entity: EntityConfig, states: CachedEntity[], range: [number, number]) {
109115
const entityKey = getEntityKey(entity);
110116
let h = (this.histories[entityKey] ??= []);
111117
h.push(...states);
@@ -122,13 +128,33 @@ export default class Cache {
122128
this.ranges = {};
123129
this.histories = {};
124130
}
125-
getHistory(entity: EntityConfig) {
131+
getHistory(entity: EntityConfig): CachedEntity[] {
126132
let key = getEntityKey(entity);
127133
const history = this.histories[key] || [];
128-
return history.map((datum) => ({
129-
...datum,
130-
timestamp: datum.timestamp + entity.offset,
131-
}));
134+
if (isEntityIdStatisticsConfig(entity)) {
135+
return (history as CachedStatisticsEntity[]).map((entry) => ({
136+
...entry,
137+
timestamp: entry.timestamp + entity.offset,
138+
value: entry[entity.statistic],
139+
}));
140+
}
141+
if (isEntityIdAttrConfig(entity)) {
142+
return (history as CachedStateEntity[]).map((entry) => ({
143+
...entry,
144+
timestamp: entry.timestamp + entity.offset,
145+
value: entry.attributes[entity.attribute],
146+
}));
147+
}
148+
if (isEntityIdStateConfig(entity)) {
149+
return (history as CachedStateEntity[]).map((entry) => ({
150+
...entry,
151+
timestamp: entry.timestamp + entity.offset,
152+
value: entry.state,
153+
}));
154+
}
155+
throw new Error(
156+
`Unrecognised fetch type for ${(entity as EntityConfig).entity}`
157+
);
132158
}
133159
async update(
134160
range: TimestampRange,
@@ -137,31 +163,37 @@ export default class Cache {
137163
significant_changes_only: boolean,
138164
minimal_response: boolean
139165
) {
140-
range = range.map((n) => Math.max(MIN_SAFE_TIMESTAMP, n)); // HA API can't handle negative years
141166
return (this.busy = this.busy
142167
.catch(() => {})
143168
.then(async () => {
144-
const promises = entities.map(async (entity) => {
145-
const entityKey = getEntityKey(entity);
146-
this.ranges[entityKey] ??= [];
147-
const offsetRange = [
148-
range[0] - entity.offset,
149-
range[1] - entity.offset,
150-
];
151-
const rangesToFetch = subtractRanges(
152-
[offsetRange],
153-
this.ranges[entityKey]
154-
);
155-
for (const aRange of rangesToFetch) {
156-
const fetchedHistory = await fetchSingleRange(
157-
hass,
158-
entity,
159-
aRange,
160-
significant_changes_only,
161-
minimal_response
169+
range = range.map((n) => Math.max(MIN_SAFE_TIMESTAMP, n)); // HA API can't handle negative years
170+
const parallelFetches = Object.values(groupBy(entities, getEntityKey));
171+
const promises = parallelFetches.flatMap(async (entityGroup) => {
172+
// Each entity in entityGroup will result in exactly the same fetch
173+
// But these may differ once the offsets PR is merged
174+
// Making these fetches sequentially ensures that the already fetched ranges of each
175+
// request are not fetched more than once
176+
for (const entity of entityGroup) {
177+
const entityKey = getEntityKey(entity);
178+
this.ranges[entityKey] ??= [];
179+
const offsetRange = [
180+
range[0] - entity.offset,
181+
range[1] - entity.offset,
182+
];
183+
const rangesToFetch = subtractRanges(
184+
[offsetRange],
185+
this.ranges[entityKey]
162186
);
163-
if (fetchedHistory === null) continue;
164-
this.add(entity, fetchedHistory.history, fetchedHistory.range);
187+
for (const aRange of rangesToFetch) {
188+
const fetchedHistory = await fetchSingleRange(
189+
hass,
190+
entity,
191+
aRange,
192+
significant_changes_only,
193+
minimal_response
194+
);
195+
this.add(entity, fetchedHistory.history, fetchedHistory.range);
196+
}
165197
}
166198
});
167199

src/cache/fetch-states.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { HomeAssistant } from "custom-card-helpers";
22
import {
3+
CachedEntity,
34
EntityIdAttrConfig,
45
EntityIdStateConfig,
5-
EntityState,
66
HassEntity,
77
isEntityIdAttrConfig,
8-
isEntityIdStateConfig,
98
} from "../types";
109

1110
async function fetchStates(
@@ -14,16 +13,14 @@ async function fetchStates(
1413
[start, end]: [Date, Date],
1514
significant_changes_only?: boolean,
1615
minimal_response?: boolean
17-
): Promise<EntityState[]> {
18-
const no_attributes_query = isEntityIdStateConfig(entity)
19-
? "no_attributes&"
20-
: "";
16+
): Promise<CachedEntity[]> {
17+
const no_attributes_query = isEntityIdAttrConfig(entity)
18+
? ""
19+
: "no_attributes&";
2120
const minimal_response_query =
22-
minimal_response && isEntityIdStateConfig(entity)
23-
? "minimal_response&"
24-
: "";
21+
minimal_response && isEntityIdAttrConfig(entity) ? "" : "minimal_response&";
2522
const significant_changes_only_query =
26-
significant_changes_only && isEntityIdStateConfig(entity) ? "1" : "0";
23+
significant_changes_only && isEntityIdAttrConfig(entity) ? "0" : "1";
2724
const uri =
2825
`history/period/${start.toISOString()}?` +
2926
`filter_entity_id=${entity.entity}&` +
@@ -43,14 +40,11 @@ async function fetchStates(
4340
)}`
4441
);
4542
}
46-
if (!list) list = [];
47-
return list
43+
return (list || [])
4844
.map((entry) => ({
4945
...entry,
50-
value: isEntityIdAttrConfig(entity)
51-
? entry.attributes[entity.attribute] || null
52-
: entry.state,
5346
timestamp: +new Date(entry.last_updated || entry.last_changed),
47+
value: "", // may be state or an attribute. Will be set when getting the history
5448
}))
5549
.filter(({ timestamp }) => timestamp);
5650
}

src/cache/fetch-statistics.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { HomeAssistant } from "custom-card-helpers";
22
import { Statistics, StatisticValue } from "../recorder-types";
3-
import { EntityIdStatisticsConfig, EntityState } from "../types";
3+
import { CachedEntity, EntityIdStatisticsConfig } from "../types";
44

55
async function fetchStatistics(
66
hass: HomeAssistant,
77
entity: EntityIdStatisticsConfig,
88
[start, end]: [Date, Date]
9-
): Promise<EntityState[]> {
9+
): Promise<CachedEntity[]> {
1010
let statistics: StatisticValue[] | null = null;
1111
try {
1212
const statsP = hass.callWS<Statistics>({
@@ -25,11 +25,12 @@ async function fetchStatistics(
2525
)}`
2626
);
2727
}
28-
if (!statistics) statistics = []; //throw new Error(`Error fetching ${entity.entity}`);
29-
return statistics.map((entry) => ({
30-
...entry,
31-
timestamp: +new Date(entry.start),
32-
value: entry[entity.statistic] ?? "",
33-
}));
28+
return (statistics || [])
29+
.map((entry) => ({
30+
...entry,
31+
timestamp: +new Date(entry.start),
32+
value: "", //depends on the statistic, will be set in getHistory
33+
}))
34+
.filter(({ timestamp }) => timestamp);
3435
}
3536
export default fetchStatistics;

src/plotly-graph-card.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import Plotly from "./plotly";
88
import {
99
Config,
1010
EntityConfig,
11-
EntityState,
1211
InputConfig,
1312
isEntityIdAttrConfig,
1413
isEntityIdStateConfig,
@@ -208,6 +207,7 @@ export class PlotlyGraph extends HTMLElement {
208207
} else if (isEntityIdStatisticsConfig(entity)) {
209208
shouldFetch = true;
210209
}
210+
211211
if (value !== undefined) {
212212
this.cache.add(
213213
entity,
@@ -220,16 +220,14 @@ export class PlotlyGraph extends HTMLElement {
220220
}
221221
if (shouldFetch) {
222222
this.fetch();
223-
}
224-
if (shouldPlot) {
223+
} else if (shouldPlot) {
225224
this.plot();
226225
}
227226
}
228227
this._hass = hass;
229228
}
230229
connectedCallback() {
231230
this.setupListeners();
232-
this.fetch().then(() => (this.contentEl.style.visibility = ""));
233231
}
234232
async withoutRelayout(fn: Function) {
235233
this.isInternalRelayout++;
@@ -741,14 +739,15 @@ export class PlotlyGraph extends HTMLElement {
741739
if (layout.paper_bgcolor) {
742740
this.titleEl.style.background = layout.paper_bgcolor as string;
743741
}
744-
await this.withoutRelayout(() =>
745-
Plotly.react(
742+
await this.withoutRelayout(async () => {
743+
await Plotly.react(
746744
this.contentEl,
747745
this.getData(),
748746
layout,
749747
this.getPlotlyConfig()
750-
)
751-
);
748+
);
749+
this.contentEl.style.visibility = "";
750+
});
752751
}
753752
// The height of your card. Home Assistant uses this to automatically
754753
// distribute all cards over the available columns.

src/types.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export type EntityConfig = EntityIdConfig & {
4848
lambda?: (
4949
y: Datum[],
5050
x: Date[],
51-
raw_entity: EntityState[]
51+
raw_entity: CachedEntity[]
5252
) => Datum[] | { x?: Datum[]; y?: Datum[] };
5353
show_value:
5454
| boolean
@@ -110,15 +110,19 @@ export function isEntityIdStatisticsConfig(
110110
}
111111

112112
export type Timestamp = number;
113-
export type EntityState = (HassEntity | StatisticValue) & {
113+
114+
export type CachedStateEntity = HassEntity & {
114115
fake_boundary_datapoint?: true;
115116
timestamp: Timestamp;
116-
value: number | string;
117+
value: number | string | null;
117118
};
118-
export type HistoryInRange = {
119-
range: [number, number];
120-
history: EntityState[];
119+
export type CachedStatisticsEntity = StatisticValue & {
120+
fake_boundary_datapoint?: true;
121+
timestamp: Timestamp;
122+
value: number | string | null;
121123
};
124+
export type CachedEntity = CachedStateEntity | CachedStatisticsEntity;
125+
122126
export type TimestampRange = Timestamp[]; // [Timestamp, Timestamp];
123127

124128
export type HATheme = {

0 commit comments

Comments
 (0)