Add hourly forecast card feature for weather entities (#51594)

This commit is contained in:
Petar Petrov
2026-04-16 16:42:10 +03:00
committed by GitHub
parent 80e0c098f8
commit b7dcbd559e
7 changed files with 364 additions and 1 deletions

View File

@@ -0,0 +1,262 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-spinner";
import type { ForecastEvent } from "../../../data/weather";
import { subscribeForecast, WeatherEntityFeature } from "../../../data/weather";
import type { HomeAssistant } from "../../../types";
import { coordinates } from "../common/graph/coordinates";
import "../components/hui-graph-base";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type {
HourlyForecastCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
export const DEFAULT_HOURS_TO_SHOW = 24;
export const supportsHourlyForecastCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
domain === "weather" &&
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_HOURLY)
);
};
@customElement("hui-hourly-forecast-card-feature")
class HuiHourlyForecastCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false, hasChanged: () => false })
public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: HourlyForecastCardFeatureConfig;
@state() private _coordinates?: [number, number][];
@state() private _yAxisOrigin?: number;
@state() private _error?: string;
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
static getStubConfig(): HourlyForecastCardFeatureConfig {
return {
type: "hourly-forecast",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-hourly-forecast-card-feature-editor");
return document.createElement(
"hui-hourly-forecast-card-feature-editor"
) as LovelaceCardFeatureEditor;
}
public setConfig(config: HourlyForecastCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._subscribeForecast();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeForecast();
}
protected firstUpdated() {
this._subscribeForecast();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("context")) {
const oldContext = changedProps.get("context") as
| LovelaceCardFeatureContext
| undefined;
if (oldContext?.entity_id !== this.context?.entity_id) {
this._unsubscribeForecast();
this._subscribeForecast();
}
}
}
protected render() {
if (
!this._config ||
!this.hass ||
!this.context ||
!supportsHourlyForecastCardFeature(this.hass, this.context)
) {
return nothing;
}
if (this._error) {
return html`
<div class="container">
<div class="info">${this._error}</div>
</div>
`;
}
if (!this._coordinates) {
return html`
<div class="container loading">
<ha-spinner size="small"></ha-spinner>
</div>
`;
}
if (!this._coordinates.length) {
return html`
<div class="container">
<div class="info">
${this.hass.localize(
"ui.panel.lovelace.editor.features.types.hourly-forecast.no_forecast"
)}
</div>
</div>
`;
}
return html`
<hui-graph-base
.coordinates=${this._coordinates}
.yAxisOrigin=${this._yAxisOrigin}
></hui-graph-base>
`;
}
private _unsubscribeForecast() {
if (this._subscribed) {
this._subscribed.then((unsub) => unsub?.()).catch(() => undefined);
this._subscribed = undefined;
}
}
private _computeCoordinates(forecastEvent: ForecastEvent) {
const entityId = this.context!.entity_id!;
const stateObj = this.hass!.states[entityId];
if (!forecastEvent.forecast?.length) {
this._coordinates = [];
return;
}
const data: [number, number][] = [];
const now = Date.now();
const hoursToShow = this._config!.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
const msPerHour = 60 * 60 * 1000;
// Round down to the nearest hour so the axis aligns with forecast data points
const maxTime =
Math.floor((now + hoursToShow * msPerHour) / msPerHour) * msPerHour;
// Start with current temperature
const currentTemp = stateObj?.attributes?.temperature;
if (currentTemp != null && !Number.isNaN(Number(currentTemp))) {
data.push([now, Number(currentTemp)]);
}
// Add forecast data points for the next 24 hours
for (const entry of forecastEvent.forecast) {
if (entry.temperature != null && !Number.isNaN(entry.temperature)) {
const time = new Date(entry.datetime).getTime();
if (time > maxTime) break;
if (time < now) continue;
data.push([time, entry.temperature]);
}
}
if (!data.length) {
this._coordinates = [];
return;
}
const { points, yAxisOrigin } = coordinates(
data,
this.clientWidth,
this.clientHeight,
data.length,
{ minX: now, maxX: maxTime }
);
// Remove the trailing flat extension point added by calcPoints
points.pop();
this._coordinates = points;
this._yAxisOrigin = yAxisOrigin;
}
private async _subscribeForecast() {
if (
!this.context?.entity_id ||
!this._config ||
!this.hass ||
this._subscribed
) {
return;
}
const entityId = this.context.entity_id;
this._subscribed = subscribeForecast(
this.hass,
entityId,
"hourly",
(forecastEvent) => {
this._computeCoordinates(forecastEvent);
}
).catch((err) => {
this._subscribed = undefined;
this._error = err.message || err.code;
return undefined;
});
}
static styles = css`
:host {
display: flex;
width: 100%;
height: var(--feature-height);
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
pointer-events: none !important;
}
.container.loading {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
hui-graph-base {
width: 100%;
--accent-color: var(--feature-color);
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
overflow: hidden;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-hourly-forecast-card-feature": HuiHourlyForecastCardFeature;
}
}

View File

@@ -241,6 +241,11 @@ export interface TrendGraphCardFeatureConfig {
detail?: boolean;
}
export interface HourlyForecastCardFeatureConfig {
type: "hourly-forecast";
hours_to_show?: number;
}
export const AREA_CONTROL_DOMAINS = [
"light",
"fan",
@@ -295,6 +300,7 @@ export type LovelaceCardFeatureConfig =
| FanPresetModesCardFeatureConfig
| FanSpeedCardFeatureConfig
| TrendGraphCardFeatureConfig
| HourlyForecastCardFeatureConfig
| HumidifierToggleCardFeatureConfig
| HumidifierModesCardFeatureConfig
| LawnMowerCommandsCardFeatureConfig

View File

@@ -19,6 +19,9 @@ export class HuiGraphBase extends LitElement {
const width = this.clientWidth || 500;
const height = this.clientHeight || width / 5;
const yAxisOrigin = this.yAxisOrigin ?? height;
const lastX = this.coordinates?.length
? this.coordinates[this.coordinates.length - 1][0]
: width;
return html`
${this._path
? svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
@@ -27,7 +30,7 @@ export class HuiGraphBase extends LitElement {
<path
class='fill'
fill='white'
d="${this._path} L ${width}, ${yAxisOrigin} L 0, ${yAxisOrigin} z"
d="${this._path} L ${lastX}, ${yAxisOrigin} L 0, ${yAxisOrigin} z"
/>
</mask>
<rect height="100%" width="100%" fill="var(--accent-color)" mask="url(#${this._uniqueId}-fill)"></rect>

View File

@@ -43,6 +43,7 @@ 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-bar-gauge-card-feature";
import "../card-features/hui-hourly-forecast-card-feature";
import "../card-features/hui-trend-graph-card-feature";
import type { LovelaceCardFeatureConfig } from "../card-features/types";
@@ -73,6 +74,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"fan-oscillate",
"fan-preset-modes",
"fan-speed",
"hourly-forecast",
"humidifier-modes",
"humidifier-toggle",
"lawn-mower-commands",

View File

@@ -42,6 +42,7 @@ import { supportsCoverTiltFavoriteCardFeature } from "../../card-features/hui-co
import { supportsCoverTiltPositionCardFeature } from "../../card-features/hui-cover-tilt-position-card-feature";
import { supportsDateSetCardFeature } from "../../card-features/hui-date-set-card-feature";
import { supportsFanDirectionCardFeature } from "../../card-features/hui-fan-direction-card-feature";
import { supportsHourlyForecastCardFeature } from "../../card-features/hui-hourly-forecast-card-feature";
import { supportsFanOscilatteCardFeature } from "../../card-features/hui-fan-oscillate-card-feature";
import { supportsFanPresetModesCardFeature } from "../../card-features/hui-fan-preset-modes-card-feature";
import { supportsFanSpeedCardFeature } from "../../card-features/hui-fan-speed-card-feature";
@@ -105,6 +106,7 @@ const UI_FEATURE_TYPES = [
"fan-oscillate",
"fan-preset-modes",
"fan-speed",
"hourly-forecast",
"humidifier-modes",
"humidifier-toggle",
"lawn-mower-commands",
@@ -148,6 +150,7 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"cover-position-favorite",
"cover-tilt-favorite",
"fan-preset-modes",
"hourly-forecast",
"humidifier-modes",
"lawn-mower-commands",
"media-player-playback",
@@ -188,6 +191,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
"fan-oscillate": supportsFanOscilatteCardFeature,
"fan-preset-modes": supportsFanPresetModesCardFeature,
"fan-speed": supportsFanSpeedCardFeature,
"hourly-forecast": supportsHourlyForecastCardFeature,
"humidifier-modes": supportsHumidifierModesCardFeature,
"humidifier-toggle": supportsHumidifierToggleCardFeature,
"lawn-mower-commands": supportsLawnMowerCommandCardFeature,

View File

@@ -0,0 +1,82 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import { DEFAULT_HOURS_TO_SHOW } from "../../card-features/hui-hourly-forecast-card-feature";
import type {
HourlyForecastCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
const SCHEMA = [
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
] as const satisfies HaFormSchema[];
@customElement("hui-hourly-forecast-card-feature-editor")
export class HuiHourlyForecastCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: HourlyForecastCardFeatureConfig;
public setConfig(config: HourlyForecastCardFeatureConfig): void {
this._config = config;
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const data = { ...this._config };
if (!this._config.hours_to_show) {
data.hours_to_show = DEFAULT_HOURS_TO_SHOW;
}
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "hours_to_show":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
default:
return "";
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-hourly-forecast-card-feature-editor": HuiHourlyForecastCardFeatureEditor;
}
}

View File

@@ -10027,6 +10027,10 @@
"trend-graph": {
"label": "Trend graph",
"detail": "Show more detail"
},
"hourly-forecast": {
"label": "Hourly forecast",
"no_forecast": "No forecast data available"
}
}
},