diff --git a/src/panels/lovelace/card-features/hui-history-chart-card-feature.ts b/src/panels/lovelace/card-features/hui-history-chart-card-feature.ts new file mode 100644 index 0000000000..7e42bf3deb --- /dev/null +++ b/src/panels/lovelace/card-features/hui-history-chart-card-feature.ts @@ -0,0 +1,301 @@ +import { css, html, LitElement, nothing, svg } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { + computeHistory, + subscribeHistoryStatesTimeWindow, +} from "../../../data/history"; +import type { + HistoryResult, + LineChartUnit, + TimelineEntity, +} from "../../../data/history"; +import type { HomeAssistant } from "../../../types"; +import type { LovelaceCardFeature } from "../types"; +import type { + LovelaceCardFeatureContext, + HistoryChartCardFeatureConfig, +} from "./types"; +import { getSensorNumericDeviceClasses } from "../../../data/sensor"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; +import { getGraphColorByIndex } from "../../../common/color/colors"; +import { computeTimelineColor } from "../../../components/chart/timeline-color"; +import { downSampleLineData } from "../../../components/chart/down-sample"; +import { fireEvent } from "../../../common/dom/fire_event"; + +export const supportsHistoryChartCardFeature = ( + _hass: HomeAssistant, + context: LovelaceCardFeatureContext +) => + !!context.entity_id && + ["sensor", "binary_sensor"].includes(computeDomain(context.entity_id)); + +@customElement("hui-history-chart-card-feature") +class HuiHistoryChartCardFeature + extends SubscribeMixin(LitElement) + implements LovelaceCardFeature +{ + @property({ attribute: false, hasChanged: () => false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) public context?: LovelaceCardFeatureContext; + + @state() private _config?: HistoryChartCardFeatureConfig; + + @state() private _stateHistory?: HistoryResult; + + private _interval?: number; + + static getStubConfig(): HistoryChartCardFeatureConfig { + return { + type: "history-chart", + hours_to_show: 24, + }; + } + + public setConfig(config: HistoryChartCardFeatureConfig): void { + if (!config) { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + public connectedCallback() { + super.connectedCallback(); + // redraw the graph every minute to update the time axis + clearInterval(this._interval); + this._interval = window.setInterval(() => this.requestUpdate(), 1000 * 60); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + clearInterval(this._interval); + } + + protected hassSubscribe() { + return [this._subscribeHistory()]; + } + + protected render() { + if ( + !this._config || + !this.hass || + !this.context || + !this._stateHistory || + !supportsHistoryChartCardFeature(this.hass, this.context) + ) { + return nothing; + } + + const line = this._stateHistory.line[0]; + const timeline = this._stateHistory.timeline[0]; + const width = this.clientWidth; + const height = this.clientHeight; + if (line) { + const points = this._generateLinePoints(line); + const { paths, filledPaths } = this._getLinePaths(points); + const color = getGraphColorByIndex(0, this.style); + + return html` +
+ ${svg` + ${paths.map( + (path) => + svg`` + )} + ${filledPaths.map( + (path) => + svg`` + )} + `} +
+ `; + } + if (timeline) { + const ranges = this._generateTimelineRanges(timeline); + return html` +
+ ${svg` + + ${ranges.map((r) => svg``)} + + `} +
+ `; + } + return nothing; + } + + private _handleClick() { + // open more info dialog to show more detailed history + fireEvent(this, "hass-more-info", { entityId: this.context!.entity_id! }); + } + + private async _subscribeHistory(): Promise<() => Promise> { + if ( + !isComponentLoaded(this.hass!, "history") || + !this.context?.entity_id || + !this._config + ) { + return () => Promise.resolve(); + } + + const { numeric_device_classes: sensorNumericDeviceClasses } = + await getSensorNumericDeviceClasses(this.hass!); + + return subscribeHistoryStatesTimeWindow( + this.hass!, + (historyStates) => { + this._stateHistory = computeHistory( + this.hass!, + historyStates, + [this.context!.entity_id!], + this.hass!.localize, + sensorNumericDeviceClasses, + false + ); + }, + this._config!.hours_to_show ?? 24, + [this.context!.entity_id!] + ); + } + + private _generateLinePoints(line: LineChartUnit): { x: number; y: number }[] { + const width = this.clientWidth; + const height = this.clientHeight; + let minY = Number(line.data[0].states[0].state); + let maxY = Number(line.data[0].states[0].state); + const minX = line.data[0].states[0].last_changed; + const maxX = Date.now(); + line.data[0].states.forEach((stateData) => { + const stateValue = Number(stateData.state); + if (stateValue < minY) { + minY = stateValue; + } + if (stateValue > maxY) { + maxY = stateValue; + } + }); + const rangeY = maxY - minY || minY * 0.1; + const sampledData = downSampleLineData( + line.data[0].states.map((stateData) => [ + stateData.last_changed, + Number(stateData.state), + ]), + width, + minX, + maxX + ); + // add margin to the min and max + minY -= rangeY * 0.1; + maxY += rangeY * 0.1; + const yDenom = maxY - minY || 1; + const xDenom = maxX - minX || 1; + const points = sampledData!.map((point) => { + const x = ((point![0] - minX) / xDenom) * width; + const y = height - ((Number(point![1]) - minY) / yDenom) * height; + return { x, y }; + }); + points.push({ x: width, y: points[points.length - 1].y }); + return points; + } + + private _generateTimelineRanges(timeline: TimelineEntity) { + if (timeline.data.length === 0) { + return []; + } + const computedStyles = getComputedStyle(this); + const width = this.clientWidth; + const minX = timeline.data[0].last_changed; + const maxX = Date.now(); + let prevEndX = 0; + let prevStateColor = ""; + const ranges = timeline.data.map((t) => { + const x = ((t.last_changed - minX) / (maxX - minX)) * width; + const range = { + startX: prevEndX, + endX: x, + color: prevStateColor, + }; + prevStateColor = computeTimelineColor( + t.state, + computedStyles, + this.hass!.states[timeline.entity_id] + ); + prevEndX = x; + return range; + }); + ranges.push({ + startX: prevEndX, + endX: width, + color: prevStateColor, + }); + return ranges; + } + + private _getLinePaths(points: { x: number; y: number }[]) { + const paths: string[] = []; + const filledPaths: string[] = []; + if (!points.length) { + return { paths, filledPaths }; + } + // path can interupted by missing data, so we need to split the path into segments + const pathSegments: { x: number; y: number }[][] = [[]]; + points.forEach((point) => { + if (!isNaN(point.y)) { + pathSegments[pathSegments.length - 1].push(point); + } else if (pathSegments[pathSegments.length - 1].length > 0) { + pathSegments.push([]); + } + }); + + pathSegments.forEach((pathPoints) => { + // create a smoothed path + let next: { x: number; y: number }; + let path = ""; + let last = pathPoints[0]; + + path += `M ${last.x},${last.y}`; + + pathPoints.forEach((coord) => { + next = coord; + path += ` ${(next.x + last.x) / 2},${(next.y + last.y) / 2}`; + path += ` Q${next.x},${next.y}`; + last = next; + }); + + path += ` ${next!.x},${next!.y}`; + paths.push(path); + filledPaths.push( + path + + ` L ${next!.x},${this.clientHeight} L ${pathPoints[0].x},${this.clientHeight} Z` + ); + }); + + return { paths, filledPaths }; + } + + static styles = css` + :host { + display: block; + width: 100%; + height: var(--feature-height); + } + :host > div { + width: 100%; + height: 100%; + cursor: pointer; + } + .timeline { + border-radius: 4px; + overflow: hidden; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-history-chart-card-feature": HuiHistoryChartCardFeature; + } +} diff --git a/src/panels/lovelace/card-features/types.ts b/src/panels/lovelace/card-features/types.ts index 6d10c24517..966346b813 100644 --- a/src/panels/lovelace/card-features/types.ts +++ b/src/panels/lovelace/card-features/types.ts @@ -183,6 +183,11 @@ export interface UpdateActionsCardFeatureConfig { backup?: "yes" | "no" | "ask"; } +export interface HistoryChartCardFeatureConfig { + type: "history-chart"; + hours_to_show: number; +} + export const AREA_CONTROLS = [ "light", "fan", @@ -236,6 +241,7 @@ export type LovelaceCardFeatureConfig = | MediaPlayerVolumeSliderCardFeatureConfig | NumericInputCardFeatureConfig | SelectOptionsCardFeatureConfig + | HistoryChartCardFeatureConfig | TargetHumidityCardFeatureConfig | TargetTemperatureCardFeatureConfig | ToggleCardFeatureConfig diff --git a/src/panels/lovelace/create-element/create-card-feature-element.ts b/src/panels/lovelace/create-element/create-card-feature-element.ts index 83919cafa6..f8b3f44ac8 100644 --- a/src/panels/lovelace/create-element/create-card-feature-element.ts +++ b/src/panels/lovelace/create-element/create-card-feature-element.ts @@ -34,6 +34,7 @@ import "../card-features/hui-valve-open-close-card-feature"; import "../card-features/hui-valve-position-card-feature"; import "../card-features/hui-water-heater-operation-modes-card-feature"; import "../card-features/hui-area-controls-card-feature"; +import "../card-features/hui-history-chart-card-feature"; import type { LovelaceCardFeatureConfig } from "../card-features/types"; import { @@ -70,6 +71,7 @@ const TYPES = new Set([ "media-player-volume-slider", "numeric-input", "select-options", + "history-chart", "target-humidity", "target-temperature", "toggle", diff --git a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts index 9e1e1d0bf2..b19805e0d9 100644 --- a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts @@ -45,6 +45,7 @@ import { supportsLockOpenDoorCardFeature } from "../../card-features/hui-lock-op import { supportsMediaPlayerVolumeSliderCardFeature } from "../../card-features/hui-media-player-volume-slider-card-feature"; import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric-input-card-feature"; import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature"; +import { supportsHistoryChartCardFeature } from "../../card-features/hui-history-chart-card-feature"; import { supportsTargetHumidityCardFeature } from "../../card-features/hui-target-humidity-card-feature"; import { supportsTargetTemperatureCardFeature } from "../../card-features/hui-target-temperature-card-feature"; import { supportsToggleCardFeature } from "../../card-features/hui-toggle-card-feature"; @@ -95,6 +96,7 @@ const UI_FEATURE_TYPES = [ "media-player-volume-slider", "numeric-input", "select-options", + "history-chart", "target-humidity", "target-temperature", "toggle", @@ -160,6 +162,7 @@ const SUPPORTS_FEATURE_TYPES: Record< "media-player-volume-slider": supportsMediaPlayerVolumeSliderCardFeature, "numeric-input": supportsNumericInputCardFeature, "select-options": supportsSelectOptionsCardFeature, + "history-chart": supportsHistoryChartCardFeature, "target-humidity": supportsTargetHumidityCardFeature, "target-temperature": supportsTargetTemperatureCardFeature, toggle: supportsToggleCardFeature, diff --git a/src/translations/en.json b/src/translations/en.json index fd8a8f10b4..d5cb18f680 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -8068,6 +8068,9 @@ "cover-window": "Windows" }, "no_compatible_controls": "No compatible controls available for this area" + }, + "history-chart": { + "label": "History chart" } } },