TS history data (#1839)

* Convert history data to TS

* Lint

* Extract cached history

* Move around
This commit is contained in:
Paulus Schoutsen 2018-10-23 13:54:52 +02:00 committed by GitHub
parent ad162677a6
commit d0cb7b9724
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 494 additions and 311 deletions

View File

@ -3,7 +3,7 @@ import { html } from "@polymer/polymer/lib/utils/html-tag.js";
import { PolymerElement } from "@polymer/polymer/polymer-element.js";
import "../components/state-history-charts.js";
import "../data/ha-state-history-data.js";
import "../data/ha-state-history-data";
import computeStateName from "../common/entity/compute_state_name.js";
import EventsMixin from "../mixins/events-mixin.js";

View File

@ -255,7 +255,7 @@ class StateHistoryChartLine extends LocalizeMixin(PolymerElement) {
Array.prototype.push.apply(datasets, data);
});
const formatTooltipTitle = function(items, data) {
const formatTooltipTitle = (items, data) => {
const item = items[0];
const date = data.datasets[item.datasetIndex].data[item.index].x;

235
src/data/cached-history.ts Normal file
View File

@ -0,0 +1,235 @@
import {
computeHistory,
fetchRecent,
HistoryResult,
TimelineEntity,
LineChartUnit,
} from "./history";
import { HomeAssistant } from "../types";
import { LocalizeFunc } from "../mixins/localize-base-mixin";
import { HassEntity } from "home-assistant-js-websocket";
interface CacheConfig {
refresh: number;
cacheKey: string;
hoursToShow: number;
}
interface CachedResults {
prom: Promise<HistoryResult>;
startTime: Date;
endTime: Date;
language: string;
data: HistoryResult;
}
// This is a different interface, a different cache :(
interface RecentCacheResults {
created: number;
language: string;
data: Promise<HistoryResult>;
}
const RECENT_THRESHOLD = 60000; // 1 minute
const RECENT_CACHE: { [cacheKey: string]: RecentCacheResults } = {};
const stateHistoryCache: { [cacheKey: string]: CachedResults } = {};
// Cached type 1 unction. Without cache config.
export const getRecent = (
hass: HomeAssistant,
entityId: string,
startTime: Date,
endTime: Date,
localize: LocalizeFunc,
language: string
) => {
const cacheKey = entityId;
const cache = RECENT_CACHE[cacheKey];
if (
cache &&
Date.now() - cache.created < RECENT_THRESHOLD &&
cache.language === language
) {
return cache.data;
}
const prom = fetchRecent(hass, entityId, startTime, endTime).then(
(stateHistory) => computeHistory(hass, stateHistory, localize, language),
(err) => {
delete RECENT_CACHE[entityId];
throw err;
}
);
RECENT_CACHE[cacheKey] = {
created: Date.now(),
language,
data: prom,
};
return prom;
};
// Cache type 2 functionality
function getEmptyCache(
language: string,
startTime: Date,
endTime: Date
): CachedResults {
return {
prom: Promise.resolve({ line: [], timeline: [] }),
language,
startTime,
endTime,
data: { line: [], timeline: [] },
};
}
export const getRecentWithCache = (
hass: HomeAssistant,
entityId: string,
cacheConfig: CacheConfig,
localize: LocalizeFunc,
language: string
) => {
const cacheKey = cacheConfig.cacheKey;
const endTime = new Date();
const startTime = new Date(endTime);
startTime.setHours(startTime.getHours() - cacheConfig.hoursToShow);
let toFetchStartTime = startTime;
let appendingToCache = false;
let cache = stateHistoryCache[cacheKey];
if (
cache &&
toFetchStartTime >= cache.startTime &&
toFetchStartTime <= cache.endTime &&
cache.language === language
) {
toFetchStartTime = cache.endTime;
appendingToCache = true;
// This pretty much never happens as endTime is usually set to now
if (endTime <= cache.endTime) {
return cache.prom;
}
} else {
cache = stateHistoryCache[cacheKey] = getEmptyCache(
language,
startTime,
endTime
);
}
const curCacheProm = cache.prom;
const genProm = async () => {
let fetchedHistory: HassEntity[][];
try {
const results = await Promise.all([
curCacheProm,
fetchRecent(
hass,
entityId,
toFetchStartTime,
endTime,
appendingToCache
),
]);
fetchedHistory = results[1];
} catch (err) {
delete stateHistoryCache[cacheKey];
throw err;
}
const stateHistory = computeHistory(
hass,
fetchedHistory,
localize,
language
);
if (appendingToCache) {
mergeLine(stateHistory.line, cache.data.line);
mergeTimeline(stateHistory.timeline, cache.data.timeline);
pruneStartTime(startTime, cache.data);
} else {
cache.data = stateHistory;
}
return cache.data;
};
cache.prom = genProm();
cache.startTime = startTime;
cache.endTime = endTime;
return cache.prom;
};
const mergeLine = (
historyLines: LineChartUnit[],
cacheLines: LineChartUnit[]
) => {
historyLines.forEach((line) => {
const unit = line.unit;
const oldLine = cacheLines.find((cacheLine) => cacheLine.unit === unit);
if (oldLine) {
line.data.forEach((entity) => {
const oldEntity = oldLine.data.find(
(cacheEntity) => entity.entity_id === cacheEntity.entity_id
);
if (oldEntity) {
oldEntity.states = oldEntity.states.concat(entity.states);
} else {
oldLine.data.push(entity);
}
});
} else {
cacheLines.push(line);
}
});
};
const mergeTimeline = (
historyTimelines: TimelineEntity[],
cacheTimelines: TimelineEntity[]
) => {
historyTimelines.forEach((timeline) => {
const oldTimeline = cacheTimelines.find(
(cacheTimeline) => cacheTimeline.entity_id === timeline.entity_id
);
if (oldTimeline) {
oldTimeline.data = oldTimeline.data.concat(timeline.data);
} else {
cacheTimelines.push(timeline);
}
});
};
const pruneArray = (originalStartTime: Date, arr) => {
if (arr.length === 0) {
return arr;
}
const changedAfterStartTime = arr.findIndex(
(state) => new Date(state.last_changed) > originalStartTime
);
if (changedAfterStartTime === 0) {
// If all changes happened after originalStartTime then we are done.
return arr;
}
// If all changes happened at or before originalStartTime. Use last index.
const updateIndex =
changedAfterStartTime === -1 ? arr.length - 1 : changedAfterStartTime - 1;
arr[updateIndex].last_changed = originalStartTime;
return arr.slice(updateIndex);
};
const pruneStartTime = (originalStartTime: Date, cacheData: HistoryResult) => {
cacheData.line.forEach((line) => {
line.data.forEach((entity) => {
entity.states = pruneArray(originalStartTime, entity.states);
});
});
cacheData.timeline.forEach((timeline) => {
timeline.data = pruneArray(originalStartTime, timeline.data);
});
};

View File

@ -2,120 +2,10 @@ import { timeOut } from "@polymer/polymer/lib/utils/async.js";
import { Debouncer } from "@polymer/polymer/lib/utils/debounce.js";
import { PolymerElement } from "@polymer/polymer/polymer-element.js";
import computeStateName from "../common/entity/compute_state_name.js";
import computeStateDomain from "../common/entity/compute_state_domain.js";
import computeStateDisplay from "../common/entity/compute_state_display.js";
import LocalizeMixin from "../mixins/localize-mixin.js";
const RECENT_THRESHOLD = 60000; // 1 minute
const RECENT_CACHE = {};
const DOMAINS_USE_LAST_UPDATED = ["thermostat", "climate", "water_heater"];
const LINE_ATTRIBUTES_TO_KEEP = [
"temperature",
"current_temperature",
"target_temp_low",
"target_temp_high",
];
const stateHistoryCache = {};
function computeHistory(hass, stateHistory, localize, language) {
const lineChartDevices = {};
const timelineDevices = [];
if (!stateHistory) {
return { line: [], timeline: [] };
}
stateHistory.forEach((stateInfo) => {
if (stateInfo.length === 0) {
return;
}
const stateWithUnit = stateInfo.find(
(state) => "unit_of_measurement" in state.attributes
);
let unit = false;
if (stateWithUnit) {
unit = stateWithUnit.attributes.unit_of_measurement;
} else if (computeStateDomain(stateInfo[0]) === "climate") {
unit = hass.config.unit_system.temperature;
} else if (computeStateDomain(stateInfo[0]) === "water_heater") {
unit = hass.config.unit_system.temperature;
}
if (!unit) {
timelineDevices.push({
name: computeStateName(stateInfo[0]),
entity_id: stateInfo[0].entity_id,
data: stateInfo
.map((state) => ({
state_localize: computeStateDisplay(localize, state, language),
state: state.state,
last_changed: state.last_changed,
}))
.filter((element, index, arr) => {
if (index === 0) return true;
return element.state !== arr[index - 1].state;
}),
});
} else if (unit in lineChartDevices) {
lineChartDevices[unit].push(stateInfo);
} else {
lineChartDevices[unit] = [stateInfo];
}
});
const unitStates = Object.keys(lineChartDevices).map((unit) => ({
unit: unit,
identifier: lineChartDevices[unit]
.map((states) => states[0].entity_id)
.join(""),
data: lineChartDevices[unit].map((states) => {
const last = states[states.length - 1];
const domain = computeStateDomain(last);
return {
domain: domain,
name: computeStateName(last),
entity_id: last.entity_id,
states: states
.map((state) => {
const result = {
state: state.state,
last_changed: state.last_changed,
};
if (DOMAINS_USE_LAST_UPDATED.includes(domain)) {
result.last_changed = state.last_updated;
}
LINE_ATTRIBUTES_TO_KEEP.forEach((attr) => {
if (attr in state.attributes) {
result.attributes = result.attributes || {};
result.attributes[attr] = state.attributes[attr];
}
});
return result;
})
.filter((element, index, arr) => {
// Remove data point if it is equal to previous point and next point.
if (index === 0 || index === arr.length - 1) return true;
function compare(obj1, obj2) {
if (obj1.state !== obj2.state) return false;
if (!obj1.attributes && !obj2.attributes) return true;
if (!obj1.attributes || !obj2.attributes) return false;
return LINE_ATTRIBUTES_TO_KEEP.every(
(attr) => obj1.attributes[attr] === obj2.attributes[attr]
);
}
return (
!compare(element, arr[index - 1]) ||
!compare(element, arr[index + 1])
);
}),
};
}),
}));
return { line: unitStates, timeline: timelineDevices };
}
import { computeHistory, fetchDate } from "./history";
import { getRecent, getRecentWithCache } from "./cached-history";
/*
* @appliesMixin LocalizeMixin
@ -225,7 +115,10 @@ class HaStateHistoryData extends LocalizeMixin(PolymerElement) {
if (filterType === "date") {
if (!startTime || !endTime) return;
data = this.getDate(startTime, endTime, localize, language);
data = fetchDate(this.hass, startTime, endTime).then((dateHistory) =>
computeHistory(this.hass, dateHistory, localize, language)
);
} else if (filterType === "recent-entity") {
if (!entityId) return;
if (cacheConfig) {
@ -236,7 +129,14 @@ class HaStateHistoryData extends LocalizeMixin(PolymerElement) {
language
);
} else {
data = this.getRecent(entityId, startTime, endTime, localize, language);
data = getRecent(
this.hass,
entityId,
startTime,
endTime,
localize,
language
);
}
} else {
return;
@ -249,14 +149,6 @@ class HaStateHistoryData extends LocalizeMixin(PolymerElement) {
});
}
getEmptyCache(language) {
return {
prom: Promise.resolve({ line: [], timeline: [] }),
language: language,
data: { line: [], timeline: [] },
};
}
getRecentWithCacheRefresh(entityId, cacheConfig, localize, language) {
if (this._refreshTimeoutId) {
window.clearInterval(this._refreshTimeoutId);
@ -264,193 +156,24 @@ class HaStateHistoryData extends LocalizeMixin(PolymerElement) {
}
if (cacheConfig.refresh) {
this._refreshTimeoutId = window.setInterval(() => {
this.getRecentWithCache(entityId, cacheConfig, localize, language).then(
(stateHistory) => {
this._setData(Object.assign({}, stateHistory));
}
);
getRecentWithCache(
this.hass,
entityId,
cacheConfig,
localize,
language
).then((stateHistory) => {
this._setData(Object.assign({}, stateHistory));
});
}, cacheConfig.refresh * 1000);
}
return this.getRecentWithCache(entityId, cacheConfig, localize, language);
}
mergeLine(historyLines, cacheLines) {
historyLines.forEach((line) => {
const unit = line.unit;
const oldLine = cacheLines.find((cacheLine) => cacheLine.unit === unit);
if (oldLine) {
line.data.forEach((entity) => {
const oldEntity = oldLine.data.find(
(cacheEntity) => entity.entity_id === cacheEntity.entity_id
);
if (oldEntity) {
oldEntity.states = oldEntity.states.concat(entity.states);
} else {
oldLine.data.push(entity);
}
});
} else {
cacheLines.push(line);
}
});
}
mergeTimeline(historyTimelines, cacheTimelines) {
historyTimelines.forEach((timeline) => {
const oldTimeline = cacheTimelines.find(
(cacheTimeline) => cacheTimeline.entity_id === timeline.entity_id
);
if (oldTimeline) {
oldTimeline.data = oldTimeline.data.concat(timeline.data);
} else {
cacheTimelines.push(timeline);
}
});
}
pruneArray(originalStartTime, arr) {
if (arr.length === 0) return arr;
const changedAfterStartTime = arr.findIndex((state) => {
const lastChanged = new Date(state.last_changed);
return lastChanged > originalStartTime;
});
if (changedAfterStartTime === 0) {
// If all changes happened after originalStartTime then we are done.
return arr;
}
// If all changes happened at or before originalStartTime. Use last index.
const updateIndex =
changedAfterStartTime === -1 ? arr.length - 1 : changedAfterStartTime - 1;
arr[updateIndex].last_changed = originalStartTime;
return arr.slice(updateIndex);
}
pruneStartTime(originalStartTime, cacheData) {
cacheData.line.forEach((line) => {
line.data.forEach((entity) => {
entity.states = this.pruneArray(originalStartTime, entity.states);
});
});
cacheData.timeline.forEach((timeline) => {
timeline.data = this.pruneArray(originalStartTime, timeline.data);
});
}
getRecentWithCache(entityId, cacheConfig, localize, language) {
const cacheKey = cacheConfig.cacheKey;
const endTime = new Date();
const originalStartTime = new Date(endTime);
originalStartTime.setHours(
originalStartTime.getHours() - cacheConfig.hoursToShow
return getRecentWithCache(
this.hass,
entityId,
cacheConfig,
localize,
language
);
let startTime = originalStartTime;
let appendingToCache = false;
let cache = stateHistoryCache[cacheKey];
if (
cache &&
startTime >= cache.startTime &&
startTime <= cache.endTime &&
cache.language === language
) {
startTime = cache.endTime;
appendingToCache = true;
if (endTime <= cache.endTime) {
return cache.prom;
}
} else {
cache = stateHistoryCache[cacheKey] = this.getEmptyCache(language);
}
// Use Promise.all in order to make sure the old and the new fetches have both completed.
const prom = Promise.all([
cache.prom,
this.fetchRecent(entityId, startTime, endTime, appendingToCache),
])
// Use only data from the new fetch. Old fetch is already stored in cache.data
.then((oldAndNew) => oldAndNew[1])
// Convert data into format state-history-chart-* understands.
.then((stateHistory) =>
computeHistory(this.hass, stateHistory, localize, language)
)
// Merge old and new.
.then((stateHistory) => {
this.mergeLine(stateHistory.line, cache.data.line);
this.mergeTimeline(stateHistory.timeline, cache.data.timeline);
if (appendingToCache) {
this.pruneStartTime(originalStartTime, cache.data);
}
return cache.data;
})
.catch((err) => {
/* eslint-disable no-console */
console.error(err);
stateHistoryCache[cacheKey] = undefined;
});
cache.prom = prom;
cache.startTime = originalStartTime;
cache.endTime = endTime;
return prom;
}
getRecent(entityId, startTime, endTime, localize, language) {
const cacheKey = entityId;
const cache = RECENT_CACHE[cacheKey];
if (
cache &&
Date.now() - cache.created < RECENT_THRESHOLD &&
cache.language === language
) {
return cache.data;
}
const prom = this.fetchRecent(entityId, startTime, endTime).then(
(stateHistory) =>
computeHistory(this.hass, stateHistory, localize, language),
() => {
RECENT_CACHE[entityId] = false;
return null;
}
);
RECENT_CACHE[cacheKey] = {
created: Date.now(),
language: language,
data: prom,
};
return prom;
}
fetchRecent(entityId, startTime, endTime, skipInitialState = false) {
let url = "history/period";
if (startTime) {
url += "/" + startTime.toISOString();
}
url += "?filter_entity_id=" + entityId;
if (endTime) {
url += "&end_time=" + endTime.toISOString();
}
if (skipInitialState) {
url += "&skip_initial_state";
}
return this.hass.callApi("GET", url);
}
getDate(startTime, endTime, localize, language) {
const filter =
startTime.toISOString() + "?end_time=" + endTime.toISOString();
const prom = this.hass
.callApi("GET", "history/period/" + filter)
.then(
(stateHistory) =>
computeHistory(this.hass, stateHistory, localize, language),
() => null
);
return prom;
}
}
customElements.define("ha-state-history-data", HaStateHistoryData);

225
src/data/history.ts Normal file
View File

@ -0,0 +1,225 @@
import computeStateName from "../common/entity/compute_state_name.js";
import computeStateDomain from "../common/entity/compute_state_domain.js";
import computeStateDisplay from "../common/entity/compute_state_display.js";
import { HassEntity } from "home-assistant-js-websocket";
import { LocalizeFunc } from "../mixins/localize-base-mixin.js";
import { HomeAssistant } from "../types.js";
const DOMAINS_USE_LAST_UPDATED = ["climate", "water_heater"];
const LINE_ATTRIBUTES_TO_KEEP = [
"temperature",
"current_temperature",
"target_temp_low",
"target_temp_high",
];
export interface LineChartState {
state: string;
last_changed: string;
attributes?: { [key: string]: any };
}
export interface LineChartEntity {
domain: string;
name: string;
entity_id: string;
states: LineChartState[];
}
export interface LineChartUnit {
unit: string;
identifier: string;
data: LineChartEntity[];
}
export interface TimelineState {
state_localize: string;
state: string;
last_changed: string;
}
export interface TimelineEntity {
name: string;
entity_id: string;
data: TimelineState[];
}
export interface HistoryResult {
line: LineChartUnit[];
timeline: TimelineEntity[];
}
export const fetchRecent = (
hass,
entityId,
startTime,
endTime,
skipInitialState = false
): Promise<HassEntity[][]> => {
let url = "history/period";
if (startTime) {
url += "/" + startTime.toISOString();
}
url += "?filter_entity_id=" + entityId;
if (endTime) {
url += "&end_time=" + endTime.toISOString();
}
if (skipInitialState) {
url += "&skip_initial_state";
}
return hass.callApi("GET", url);
};
export const fetchDate = (
hass: HomeAssistant,
startTime: Date,
endTime: Date
): Promise<HassEntity[][]> => {
return hass.callApi(
"GET",
`history/period/${startTime.toISOString()}?end_time=${endTime.toISOString()}`
);
};
const equalState = (obj1: LineChartState, obj2: LineChartState) =>
obj1.state === obj2.state &&
// They either both have an attributes object or not
(!obj1.attributes ||
LINE_ATTRIBUTES_TO_KEEP.every(
(attr) => obj1.attributes![attr] === obj2.attributes![attr]
));
const processTimelineEntity = (
localize: LocalizeFunc,
language: string,
states: HassEntity[]
): TimelineEntity => {
const data: TimelineState[] = [];
for (const state of states) {
if (data.length > 0 && state.state === data[data.length - 1].state) {
continue;
}
data.push({
state_localize: computeStateDisplay(localize, state, language),
state: state.state,
last_changed: state.last_changed,
});
}
return {
name: computeStateName(states[0]),
entity_id: states[0].entity_id,
data,
};
};
const processLineChartEntities = (
unit,
entities: HassEntity[][]
): LineChartUnit => {
const data: LineChartEntity[] = [];
for (const states of entities) {
const last: HassEntity = states[states.length - 1];
const domain = computeStateDomain(last);
const processedStates: LineChartState[] = [];
for (const state of states) {
let processedState: LineChartState;
if (DOMAINS_USE_LAST_UPDATED.includes(domain)) {
processedState = {
state: state.state,
last_changed: state.last_updated,
attributes: {},
};
for (const attr of LINE_ATTRIBUTES_TO_KEEP) {
if (attr in state.attributes) {
processedState.attributes![attr] = state.attributes[attr];
}
}
} else {
processedState = state;
}
if (
processedStates.length > 1 &&
equalState(
processedState,
processedStates[processedStates.length - 1]
) &&
equalState(processedState, processedStates[processedStates.length - 2])
) {
continue;
}
processedStates.push(processedState);
}
data.push({
domain,
name: computeStateName(last),
entity_id: last.entity_id,
states: processedStates,
});
}
return {
unit,
identifier: entities.map((states) => states[0].entity_id).join(""),
data,
};
};
export const computeHistory = (
hass: HomeAssistant,
stateHistory: HassEntity[][],
localize: LocalizeFunc,
language: string
): HistoryResult => {
const lineChartDevices: { [unit: string]: HassEntity[][] } = {};
const timelineDevices: TimelineEntity[] = [];
if (!stateHistory) {
return { line: [], timeline: [] };
}
stateHistory.forEach((stateInfo) => {
if (stateInfo.length === 0) {
return;
}
const stateWithUnit = stateInfo.find(
(state) => "unit_of_measurement" in state.attributes
);
let unit: string | undefined;
if (stateWithUnit) {
unit = stateWithUnit.attributes.unit_of_measurement;
} else if (computeStateDomain(stateInfo[0]) === "climate") {
unit = hass.config.unit_system.temperature;
} else if (computeStateDomain(stateInfo[0]) === "water_heater") {
unit = hass.config.unit_system.temperature;
}
if (!unit) {
timelineDevices.push(
processTimelineEntity(localize, language, stateInfo)
);
} else if (unit in lineChartDevices) {
lineChartDevices[unit].push(stateInfo);
} else {
lineChartDevices[unit] = [stateInfo];
}
});
const unitStates = Object.keys(lineChartDevices).map((unit) =>
processLineChartEntities(unit, lineChartDevices[unit])
);
return { line: unitStates, timeline: timelineDevices };
};

View File

@ -5,7 +5,7 @@ import { html } from "@polymer/polymer/lib/utils/html-tag.js";
import { PolymerElement } from "@polymer/polymer/polymer-element.js";
import "../../components/state-history-charts.js";
import "../../data/ha-state-history-data.js";
import "../../data/ha-state-history-data";
import "../../resources/ha-style.js";
import "../../state-summary/state-card-content.js";

View File

@ -12,7 +12,7 @@ import "@vaadin/vaadin-date-picker/vaadin-date-picker.js";
import "../../components/ha-menu-button.js";
import "../../components/state-history-charts.js";
import "../../data/ha-state-history-data.js";
import "../../data/ha-state-history-data";
import "../../resources/ha-date-picker-style.js";
import "../../resources/ha-style.js";

View File

@ -3,7 +3,7 @@ import { PolymerElement } from "@polymer/polymer/polymer-element.js";
import "../../../components/ha-card.js";
import "../../../components/state-history-charts.js";
import "../../../data/ha-state-history-data.js";
import "../../../data/ha-state-history-data";
import processConfigEntities from "../common/process-config-entities.js";