From 5f765e8b96e36a9ef4999d620fea3bfd191a5257 Mon Sep 17 00:00:00 2001 From: Hoytron Date: Tue, 31 Mar 2020 23:25:44 +0200 Subject: [PATCH] Add feature map history (#5331) * add feature: show a geocode history on hui-map-card * refactor feature to use hass cache via rest api and omit osm request * prepare for PR * squash duplicates of allEntities to omit duplicated layers on the map * refactor to use device_tracker entity * add asdf's .tool-versions file to gitignore * add lokalize and cleanup * ajust logic to match backend api * add changes to fit new backend behaviour * fix error in history ts * cleanup history for map card --- .gitignore | 3 + src/data/history.ts | 6 +- src/panels/lovelace/cards/hui-map-card.ts | 183 +++++++++++++++++- src/panels/lovelace/cards/types.ts | 1 + .../config-elements/hui-map-card-editor.ts | 38 +++- src/panels/map/ha-entity-marker.js | 7 +- src/translations/en.json | 1 + 7 files changed, 221 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 8b568f884c..7a0283af66 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ src/cast/dev_const.ts # Secrets .lokalise_token yarn-error.log + +#asdf +.tool-versions diff --git a/src/data/history.ts b/src/data/history.ts index 72a25dde92..1055ed0472 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -55,7 +55,8 @@ export const fetchRecent = ( entityId, startTime, endTime, - skipInitialState = false + skipInitialState = false, + significantChangesOnly?: boolean ): Promise => { let url = "history/period"; if (startTime) { @@ -68,6 +69,9 @@ export const fetchRecent = ( if (skipInitialState) { url += "&skip_initial_state"; } + if (significantChangesOnly !== undefined) { + url += `&significant_changes_only=${Number(significantChangesOnly)}`; + } return hass.callApi("GET", url); }; diff --git a/src/panels/lovelace/cards/hui-map-card.ts b/src/panels/lovelace/cards/hui-map-card.ts index 6c751696d7..445177e44b 100644 --- a/src/panels/lovelace/cards/hui-map-card.ts +++ b/src/panels/lovelace/cards/hui-map-card.ts @@ -1,5 +1,13 @@ import "@polymer/paper-icon-button/paper-icon-button"; -import { Layer, Marker, Circle, Map } from "leaflet"; +import { + Layer, + Marker, + Circle, + Map, + CircleMarker, + Polyline, + LatLngTuple, +} from "leaflet"; import { LitElement, TemplateResult, @@ -10,7 +18,6 @@ import { CSSResult, customElement, } from "lit-element"; - import "../../map/ha-entity-marker"; import { @@ -32,6 +39,9 @@ import { MapCardConfig } from "./types"; import { classMap } from "lit-html/directives/class-map"; import { findEntities } from "../common/find-entites"; +import { HassEntity } from "home-assistant-js-websocket"; +import { fetchRecent } from "../../../data/history"; + @customElement("hui-map-card") class HuiMapCard extends LitElement implements LovelaceCard { public static async getConfigElement() { @@ -66,6 +76,10 @@ class HuiMapCard extends LitElement implements LovelaceCard { @property({ type: Boolean, reflect: true }) public editMode = false; + @property() + private _history?: HassEntity[][]; + private _date?: Date; + @property() private _config?: MapCardConfig; private _configEntities?: EntityConfig[]; @@ -86,7 +100,24 @@ class HuiMapCard extends LitElement implements LovelaceCard { ); private _mapItems: Array = []; private _mapZones: Array = []; + private _mapPaths: Array = []; private _connected = false; + private _colorDict: { [key: string]: string } = {}; + private _colorIndex: number = 0; + private _colors: string[] = [ + "#0288D1", + "#00AA00", + "#984ea3", + "#00d2d5", + "#ff7f00", + "#af8d00", + "#7f80cd", + "#b3e900", + "#c42e60", + "#a65628", + "#f781bf", + "#8dd3c7", + ]; public setConfig(config: MapCardConfig): void { if (!config) { @@ -112,6 +143,8 @@ class HuiMapCard extends LitElement implements LovelaceCard { this._configEntities = config.entities ? processConfigEntities(config.entities) : []; + + this._cleanupHistory(); } public getCardSize(): number { @@ -223,7 +256,7 @@ class HuiMapCard extends LitElement implements LovelaceCard { } protected updated(changedProps: PropertyValues): void { - if (changedProps.has("hass")) { + if (changedProps.has("hass") || changedProps.has("_history")) { this._drawEntities(); this._fitMap(); } @@ -233,6 +266,15 @@ class HuiMapCard extends LitElement implements LovelaceCard { ) { this.updateMap(changedProps.get("_config") as MapCardConfig); } + + if (this._config!.hours_to_show && this._configEntities?.length) { + const minute = 60000; + if (changedProps.has("_config")) { + this._getHistory(); + } else if (Date.now() - this._date!.getTime() >= minute) { + this._getHistory(); + } + } } private get _mapEl(): HTMLDivElement { @@ -285,9 +327,7 @@ class HuiMapCard extends LitElement implements LovelaceCard { return; } - const bounds = this.Leaflet.latLngBounds( - this._mapItems ? this._mapItems.map((item) => item.getLatLng()) : [] - ); + const bounds = this.Leaflet.featureGroup(this._mapItems).getBounds(); this._leafletMap.fitBounds(bounds.pad(0.5)); if (zoom && this._leafletMap.getZoom() > zoom) { @@ -295,6 +335,18 @@ class HuiMapCard extends LitElement implements LovelaceCard { } } + private _getColor(entityId: string) { + let color; + if (this._colorDict[entityId]) { + color = this._colorDict[entityId]; + } else { + color = this._colors[this._colorIndex]; + this._colorIndex = (this._colorIndex + 1) % this._colors.length; + this._colorDict[entityId] = color; + } + return color; + } + private _drawEntities(): void { const hass = this.hass; const map = this._leafletMap; @@ -314,6 +366,11 @@ class HuiMapCard extends LitElement implements LovelaceCard { } const mapZones: Layer[] = (this._mapZones = []); + if (this._mapPaths) { + this._mapPaths.forEach((marker) => marker.remove()); + } + const mapPaths: Layer[] = (this._mapPaths = []); + const allEntities = this._configEntities!.concat(); // Calculate visible geo location sources @@ -331,6 +388,60 @@ class HuiMapCard extends LitElement implements LovelaceCard { } } + // DRAW history + if (this._config!.hours_to_show && this._history) { + for (const entityStates of this._history) { + if (entityStates?.length <= 1) { + continue; + } + const entityId = entityStates[0].entity_id; + + // filter location data from states and remove all invalid locations + const path = entityStates.reduce( + (accumulator: LatLngTuple[], state) => { + const latitude = state.attributes.latitude; + const longitude = state.attributes.longitude; + if (latitude && longitude) { + accumulator.push([latitude, longitude] as LatLngTuple); + } + return accumulator; + }, + [] + ) as LatLngTuple[]; + + // DRAW HISTORY + for ( + let markerIndex = 0; + markerIndex < path.length - 1; + markerIndex++ + ) { + const opacityStep = 0.8 / (path.length - 2); + const opacity = 0.2 + markerIndex * opacityStep; + + // DRAW history path dots + mapPaths.push( + Leaflet.circleMarker(path[markerIndex], { + radius: 3, + color: this._getColor(entityId), + opacity, + interactive: false, + }) + ); + + // DRAW history path lines + const line = [path[markerIndex], path[markerIndex + 1]]; + mapPaths.push( + Leaflet.polyline(line, { + color: this._getColor(entityId), + opacity, + interactive: false, + }) + ); + } + } + } + + // DRAW entities for (const entity of allEntities) { const entityId = entity.entity; const stateObj = hass.states[entityId]; @@ -414,6 +525,7 @@ class HuiMapCard extends LitElement implements LovelaceCard { entity-id="${entityId}" entity-name="${entityName}" entity-picture="${entityPicture || ""}" + entity-color="${this._getColor(entityId)}" > `, iconSize: [48, 48], @@ -428,7 +540,7 @@ class HuiMapCard extends LitElement implements LovelaceCard { mapItems.push( Leaflet.circle([latitude, longitude], { interactive: false, - color: "#0288D1", + color: this._getColor(entityId), radius: gpsAccuracy, }) ); @@ -437,6 +549,7 @@ class HuiMapCard extends LitElement implements LovelaceCard { this._mapItems.forEach((marker) => map.addLayer(marker)); this._mapZones.forEach((marker) => map.addLayer(marker)); + this._mapPaths.forEach((marker) => map.addLayer(marker)); } private _attachObserver(): void { @@ -455,6 +568,62 @@ class HuiMapCard extends LitElement implements LovelaceCard { } } + private async _getHistory(): Promise { + this._date = new Date(); + + if (!this._configEntities) { + return; + } + + const entityIds = this._configEntities!.map((entity) => entity.entity).join( + "," + ); + const endTime = new Date(); + const startTime = new Date(); + startTime.setHours(endTime.getHours() - this._config!.hours_to_show!); + const skipInitialState = false; + const significantChangesOnly = false; + + const stateHistory = await fetchRecent( + this.hass, + entityIds, + startTime, + endTime, + skipInitialState, + significantChangesOnly + ); + + if (stateHistory.length < 1) { + return; + } + + this._history = stateHistory; + } + + private _cleanupHistory() { + if (!this._history) { + return; + } + if (this._config!.hours_to_show! <= 0) { + this._history = undefined; + } else { + // remove unused entities + const configEntityIds = this._configEntities?.map( + (configEntity) => configEntity.entity + ); + this._history = this._history!.reduce( + (accumulator: HassEntity[][], entityStates) => { + const entityId = entityStates[0].entity_id; + if (configEntityIds?.includes(entityId)) { + accumulator.push(entityStates); + } + return accumulator; + }, + [] + ) as HassEntity[][]; + } + } + static get styles(): CSSResult { return css` :host([ispanel]) ha-card { diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 64b5f5e234..77f910c082 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -149,6 +149,7 @@ export interface MapCardConfig extends LovelaceCardConfig { aspect_ratio?: string; default_zoom?: number; entities?: Array; + hours_to_show?: number; geo_location_sources?: string[]; dark_mode?: boolean; } diff --git a/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts index 17735537a1..58a880eebf 100644 --- a/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts @@ -34,6 +34,7 @@ const cardConfigStruct = struct({ default_zoom: "number?", dark_mode: "boolean?", entities: [entitiesConfigStruct], + hours_to_show: "number?", geo_location_sources: "array?", }); @@ -48,7 +49,9 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor { public setConfig(config: MapCardConfig): void { config = cardConfigStruct(config); this._config = config; - this._configEntities = processEditorEntities(config.entities); + this._configEntities = config.entities + ? processEditorEntities(config.entities) + : []; } get _title(): string { @@ -67,6 +70,10 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor { return this._config!.geo_location_sources || []; } + get _hours_to_show(): number { + return this._config!.hours_to_show || 0; + } + get _dark_mode(): boolean { return this._config!.dark_mode || false; } @@ -112,14 +119,27 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor { @value-changed="${this._valueChanged}" > - ${this.hass.localize( - "ui.panel.lovelace.editor.card.map.dark_mode" - )} +
+ ${this.hass.localize( + "ui.panel.lovelace.editor.card.map.dark_mode" + )} + +
-
+