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`
`}
+
+ `;
+ }
+ if (timeline) {
+ const ranges = this._generateTimelineRanges(timeline);
+ return html`
+
+ ${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"
}
}
},