Compare commits

...

8 Commits

Author SHA1 Message Date
Petar Petrov
74ba4b83d5 Type states context consumer 2026-04-20 17:18:13 +03:00
Petar Petrov
70951c362b Reduce opacity of current temp line 2026-04-20 15:35:59 +03:00
Petar Petrov
aa7af2e32b Use connection instead of hass for subscribeForecast 2026-04-20 15:30:26 +03:00
Petar Petrov
7203bbee98 Narrow support/resolve helpers to accept stateObj 2026-04-20 15:26:38 +03:00
Petar Petrov
3b298e35fd Use internationalizationContext for localize 2026-04-20 15:23:43 +03:00
Petar Petrov
21dbc5acd4 Use statesContext for current temp state 2026-04-20 14:53:25 +03:00
Petar Petrov
94df8b3b50 Address review feedback: validate forecast type, refresh on state change 2026-04-20 14:47:10 +03:00
Petar Petrov
10d7205a57 Add daily forecast card feature 2026-04-20 14:29:02 +03:00
11 changed files with 553 additions and 6 deletions

View File

@@ -21,6 +21,7 @@ import {
mdiWeatherWindyVariant,
} from "@mdi/js";
import type {
Connection,
HassConfig,
HassEntityAttributeBase,
HassEntityBase,
@@ -667,12 +668,12 @@ export const getForecast = (
};
export const subscribeForecast = (
hass: HomeAssistant,
connection: Connection,
entity_id: string,
forecast_type: ModernForecastType,
callback: (forecastevent: ForecastEvent) => void
) =>
hass.connection.subscribeMessage<ForecastEvent>(callback, {
connection.subscribeMessage<ForecastEvent>(callback, {
type: "weather/subscribe_forecast",
forecast_type,
entity_id,

View File

@@ -72,7 +72,7 @@ class MoreInfoWeather extends LitElement {
}
this._subscribed = subscribeForecast(
this.hass!,
this.hass!.connection,
this.stateObj!.entity_id,
this._forecastType,
(event) => {

View File

@@ -0,0 +1,379 @@
import { consume } from "@lit/context";
import type {
Connection,
HassEntities,
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../../../common/decorators/transform";
import { computeDomain } from "../../../common/entity/compute_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { slugify } from "../../../common/string/slugify";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-spinner";
import {
connectionContext,
internationalizationContext,
statesContext,
} from "../../../data/context";
import type { ForecastAttribute, ForecastEvent } from "../../../data/weather";
import { subscribeForecast, WeatherEntityFeature } from "../../../data/weather";
import type {
HomeAssistantConnection,
HomeAssistantInternationalization,
} from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type {
DailyForecastCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
export const DEFAULT_DAYS_TO_SHOW = 7;
const MAX_BAR_WIDTH = 12;
export type DailyForecastType = "daily" | "twice_daily";
export const supportsDailyForecastCardFeature = (
stateObj: HassEntity | undefined
) => {
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
domain === "weather" &&
(supportsFeature(stateObj, WeatherEntityFeature.FORECAST_DAILY) ||
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_TWICE_DAILY))
);
};
export const resolveDailyForecastType = (
stateObj: HassEntity | undefined,
configured?: DailyForecastType
): DailyForecastType | undefined => {
if (!stateObj) return undefined;
const supportsDaily = supportsFeature(
stateObj,
WeatherEntityFeature.FORECAST_DAILY
);
const supportsTwiceDaily = supportsFeature(
stateObj,
WeatherEntityFeature.FORECAST_TWICE_DAILY
);
if (configured === "daily" && supportsDaily) return "daily";
if (configured === "twice_daily" && supportsTwiceDaily) return "twice_daily";
if (supportsDaily) return "daily";
if (supportsTwiceDaily) return "twice_daily";
return undefined;
};
@customElement("hui-daily-forecast-card-feature")
class HuiDailyForecastCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state()
@consume({ context: statesContext, subscribe: true })
@transform<HassEntities, HassEntity | undefined>({
transformer: function (this: HuiDailyForecastCardFeature, states) {
return this.context?.entity_id
? states?.[this.context.entity_id]
: undefined;
},
watch: ["context"],
})
private _stateObj?: HassEntity;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, LocalizeFunc>({
transformer: ({ localize }) => localize,
})
private _localize!: LocalizeFunc;
@state()
@consume({ context: connectionContext, subscribe: true })
@transform<HomeAssistantConnection, Connection>({
transformer: ({ connection }) => connection,
})
private _connection!: Connection;
@state() private _config?: DailyForecastCardFeatureConfig;
@state() private _forecast?: ForecastAttribute[];
@state() private _error?: string;
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
private _subscribedType?: DailyForecastType;
static getStubConfig(): DailyForecastCardFeatureConfig {
return {
type: "daily-forecast",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-daily-forecast-card-feature-editor");
return document.createElement(
"hui-daily-forecast-card-feature-editor"
) as LovelaceCardFeatureEditor;
}
public setConfig(config: DailyForecastCardFeatureConfig): 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) {
const resolvedType = this._resolvedForecastType();
const contextChanged =
changedProps.has("context") &&
(changedProps.get("context") as LovelaceCardFeatureContext | undefined)
?.entity_id !== this.context?.entity_id;
const configTypeChanged =
changedProps.has("_config") && resolvedType !== this._subscribedType;
if (contextChanged || configTypeChanged) {
this._unsubscribeForecast();
this._subscribeForecast();
}
}
private _resolvedForecastType(): DailyForecastType | undefined {
return resolveDailyForecastType(
this._stateObj,
this._config?.forecast_type
);
}
protected render() {
if (
!this._config ||
!this.context ||
!supportsDailyForecastCardFeature(this._stateObj)
) {
return nothing;
}
if (this._error) {
return html`
<div class="container">
<div class="info">${this._error}</div>
</div>
`;
}
if (!this._forecast) {
return html`
<div class="container loading">
<ha-spinner size="small"></ha-spinner>
</div>
`;
}
const daysToShow = this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW;
const entriesPerDay = this._subscribedType === "twice_daily" ? 2 : 1;
const entries = this._forecast
.filter(
(entry) =>
entry.temperature != null &&
!Number.isNaN(entry.temperature) &&
entry.templow != null &&
!Number.isNaN(entry.templow)
)
.slice(0, daysToShow * entriesPerDay);
if (!entries.length) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.no_forecast"
)}
</div>
</div>
`;
}
return html` <div class="container">${this._renderChart(entries)}</div> `;
}
private _renderChart(entries: ForecastAttribute[]): TemplateResult {
const width = this.clientWidth || 300;
const height = this.clientHeight || 42;
const padding = 4;
const minGap = 4;
const slotWidth = width / entries.length;
const barWidth = Math.max(1, Math.min(MAX_BAR_WIDTH, slotWidth - minGap));
const currentTemp = Number(this._stateObj?.attributes?.temperature);
const hasCurrentTemp = currentTemp != null && !Number.isNaN(currentTemp);
let tempMin = Infinity;
let tempMax = -Infinity;
for (const entry of entries) {
tempMin = Math.min(tempMin, entry.templow!);
tempMax = Math.max(tempMax, entry.temperature);
}
if (hasCurrentTemp) {
tempMin = Math.min(tempMin, currentTemp);
tempMax = Math.max(tempMax, currentTemp);
}
if (tempMin === tempMax) {
tempMin -= 1;
tempMax += 1;
}
const drawableHeight = height - padding * 2;
const yFor = (value: number) =>
padding +
drawableHeight -
((value - tempMin) / (tempMax - tempMin)) * drawableHeight;
const bars = entries.map((entry, i) => {
const x = slotWidth * i + (slotWidth - barWidth) / 2;
const yHigh = yFor(entry.temperature);
const yLow = yFor(entry.templow!);
const barHeight = Math.max(1, yLow - yHigh);
const rx = Math.min(barWidth / 2, barHeight / 2);
const fill = entry.condition
? `var(--state-weather-${slugify(entry.condition, "_")}-color, var(--feature-color))`
: "var(--feature-color)";
return svg`<rect
x=${x}
y=${yHigh}
width=${barWidth}
height=${barHeight}
rx=${rx}
ry=${rx}
fill=${fill}
></rect>`;
});
const currentTempLine = hasCurrentTemp
? svg`<line
x1="0"
x2=${width}
y1=${yFor(currentTemp)}
y2=${yFor(currentTemp)}
stroke="var(--feature-color)"
stroke-width="1"
stroke-opacity="0.5"
vector-effect="non-scaling-stroke"
></line>`
: nothing;
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${bars}${currentTempLine}
</svg>
`;
}
private _unsubscribeForecast() {
if (this._subscribed) {
this._subscribed.then((unsub) => unsub?.()).catch(() => undefined);
this._subscribed = undefined;
}
this._subscribedType = undefined;
}
private async _subscribeForecast() {
if (
!this.context?.entity_id ||
!this._config ||
!this._connection ||
this._subscribed
) {
return;
}
const forecastType = this._resolvedForecastType();
if (!forecastType) {
return;
}
const entityId = this.context.entity_id;
this._forecast = undefined;
this._error = undefined;
this._subscribedType = forecastType;
this._subscribed = subscribeForecast(
this._connection,
entityId,
forecastType,
(forecastEvent: ForecastEvent) => {
this._forecast = forecastEvent.forecast ?? [];
}
).catch((err) => {
this._subscribed = undefined;
this._subscribedType = 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: stretch;
pointer-events: none !important;
}
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
overflow: hidden;
}
.info {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
svg {
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-daily-forecast-card-feature": HuiDailyForecastCardFeature;
}
}

View File

@@ -214,7 +214,7 @@ class HuiHourlyForecastCardFeature
const entityId = this.context.entity_id;
this._subscribed = subscribeForecast(
this.hass,
this.hass.connection,
entityId,
"hourly",
(forecastEvent) => {

View File

@@ -246,6 +246,12 @@ export interface HourlyForecastCardFeatureConfig {
hours_to_show?: number;
}
export interface DailyForecastCardFeatureConfig {
type: "daily-forecast";
forecast_type?: "daily" | "twice_daily";
days_to_show?: number;
}
export const AREA_CONTROL_DOMAINS = [
"light",
"fan",
@@ -301,6 +307,7 @@ export type LovelaceCardFeatureConfig =
| FanSpeedCardFeatureConfig
| TrendGraphCardFeatureConfig
| HourlyForecastCardFeatureConfig
| DailyForecastCardFeatureConfig
| HumidifierToggleCardFeatureConfig
| HumidifierModesCardFeatureConfig
| LawnMowerCommandsCardFeatureConfig

View File

@@ -135,7 +135,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
}
this._subscribed = subscribeForecast(
this.hass!,
this.hass!.connection,
this._config!.entity,
this._config!.forecast_type as "daily" | "hourly" | "twice_daily",
(event) => {

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-daily-forecast-card-feature";
import "../card-features/hui-hourly-forecast-card-feature";
import "../card-features/hui-trend-graph-card-feature";
@@ -69,6 +70,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"cover-tilt-favorite",
"cover-tilt-position",
"cover-tilt",
"daily-forecast",
"date-set",
"fan-direction",
"fan-oscillate",

View File

@@ -40,6 +40,7 @@ import { supportsCoverPositionCardFeature } from "../../card-features/hui-cover-
import { supportsCoverTiltCardFeature } from "../../card-features/hui-cover-tilt-card-feature";
import { supportsCoverTiltFavoriteCardFeature } from "../../card-features/hui-cover-tilt-favorite-card-feature";
import { supportsCoverTiltPositionCardFeature } from "../../card-features/hui-cover-tilt-position-card-feature";
import { supportsDailyForecastCardFeature } from "../../card-features/hui-daily-forecast-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";
@@ -101,6 +102,7 @@ const UI_FEATURE_TYPES = [
"cover-tilt-favorite",
"cover-tilt-position",
"cover-tilt",
"daily-forecast",
"date-set",
"fan-direction",
"fan-oscillate",
@@ -149,6 +151,7 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"counter-actions",
"cover-position-favorite",
"cover-tilt-favorite",
"daily-forecast",
"fan-preset-modes",
"hourly-forecast",
"humidifier-modes",
@@ -186,6 +189,10 @@ const SUPPORTS_FEATURE_TYPES: Record<
"cover-tilt-favorite": supportsCoverTiltFavoriteCardFeature,
"cover-tilt-position": supportsCoverTiltPositionCardFeature,
"cover-tilt": supportsCoverTiltCardFeature,
"daily-forecast": (hass, context) =>
supportsDailyForecastCardFeature(
context.entity_id ? hass.states[context.entity_id] : undefined
),
"date-set": supportsDateSetCardFeature,
"fan-direction": supportsFanDirectionCardFeature,
"fan-oscillate": supportsFanOscilatteCardFeature,

View File

@@ -0,0 +1,142 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import { getSupportedForecastTypes } from "../../../../data/weather";
import type { HomeAssistant } from "../../../../types";
import {
DEFAULT_DAYS_TO_SHOW,
resolveDailyForecastType,
} from "../../card-features/hui-daily-forecast-card-feature";
import type {
DailyForecastCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-daily-forecast-card-feature-editor")
export class HuiDailyForecastCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: DailyForecastCardFeatureConfig;
public setConfig(config: DailyForecastCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(
supportsDaily: boolean,
supportsTwiceDaily: boolean,
localize: HomeAssistant["localize"]
) =>
[
{
name: "forecast_type",
required: true,
disabled: !(supportsDaily && supportsTwiceDaily),
selector: {
select: {
mode: "dropdown",
options: [
{
value: "daily",
label: localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.forecast_type_options.daily"
),
disabled: !supportsDaily,
},
{
value: "twice_daily",
label: localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.forecast_type_options.twice_daily"
),
disabled: !supportsTwiceDaily,
},
],
},
},
},
{
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const stateObj = this.context?.entity_id
? this.hass.states[this.context.entity_id]
: undefined;
const supportedTypes = stateObj ? getSupportedForecastTypes(stateObj) : [];
const supportsDaily = supportedTypes.includes("daily");
const supportsTwiceDaily = supportedTypes.includes("twice_daily");
const resolvedType =
resolveDailyForecastType(stateObj, this._config.forecast_type) || "daily";
const data: DailyForecastCardFeatureConfig = {
...this._config,
forecast_type: resolvedType,
days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW,
};
const schema = this._schema(
supportsDaily,
supportsTwiceDaily,
this.hass.localize
);
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<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "forecast_type":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.forecast_type"
);
case "days_to_show":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
default:
return "";
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-daily-forecast-card-feature-editor": HuiDailyForecastCardFeatureEditor;
}
}

View File

@@ -50,7 +50,7 @@ class HuiWeatherEntityRow extends LitElement implements LovelaceRow {
const forecastType = getDefaultForecastType(stateObj);
if (forecastType) {
this._subscribed = subscribeForecast(
this.hass!,
this.hass!.connection,
stateObj.entity_id,
forecastType,
(event) => {

View File

@@ -10043,6 +10043,15 @@
"hourly-forecast": {
"label": "Hourly forecast",
"no_forecast": "No forecast data available"
},
"daily-forecast": {
"label": "Daily forecast",
"no_forecast": "[%key:ui::panel::lovelace::editor::features::types::hourly-forecast::no_forecast%]",
"forecast_type": "Forecast type",
"forecast_type_options": {
"daily": "Daily",
"twice_daily": "Twice daily"
}
}
}
},