mirror of
https://github.com/home-assistant/frontend.git
synced 2026-04-25 20:12:48 +00:00
Add hourly forecast card feature for weather entities (#51594)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10027,6 +10027,10 @@
|
||||
"trend-graph": {
|
||||
"label": "Trend graph",
|
||||
"detail": "Show more detail"
|
||||
},
|
||||
"hourly-forecast": {
|
||||
"label": "Hourly forecast",
|
||||
"no_forecast": "No forecast data available"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user