Compare commits

...

14 Commits

Author SHA1 Message Date
Aidan Timson 030e8c122c Revert 2026-04-14 13:03:11 +01:00
Aidan Timson db7da2b521 Restore 2026-04-14 13:01:36 +01:00
Aidan Timson d8e7ade6e1 Use const 2026-04-14 13:01:01 +01:00
Aidan Timson 7f79075c27 Restore 2026-04-14 12:59:59 +01:00
Aidan Timson b4635b907b Defaults 2026-04-14 12:54:13 +01:00
Aidan Timson cc37adfe7b Default to 12 max at 48 (Theres no technical limit) 2026-04-14 12:31:40 +01:00
Aidan Timson 7f9e2f0d48 Only render day if not today 2026-04-14 12:22:49 +01:00
Aidan Timson 3c06ef46ad Split 2026-04-14 12:19:48 +01:00
Aidan Timson a159e746e2 Show day in seperate label only item 2026-04-14 12:17:32 +01:00
Aidan Timson 7981d028cd Add customisation 2026-04-14 12:03:47 +01:00
Aidan Timson 36f7b17465 Pass missing position prop 2026-04-14 11:47:09 +01:00
Aidan Timson 2b0c3c4c22 Tweaks 2026-04-14 11:44:23 +01:00
Aidan Timson 80e610bc50 Remove gap 2026-04-14 11:19:04 +01:00
Aidan Timson b0832f3c27 Create weather forecast card feature 2026-04-14 11:16:03 +01:00
7 changed files with 681 additions and 0 deletions
@@ -0,0 +1,448 @@
import { css, html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { computeDomain } from "../../../common/entity/compute_domain";
import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
import { formatTime } from "../../../common/datetime/format_time";
import { formatNumber } from "../../../common/number/format_number";
import { DragScrollController } from "../../../common/controllers/drag-scroll-controller";
import type {
ForecastAttribute,
ForecastEvent,
ForecastType,
ModernForecastType,
WeatherEntity,
} from "../../../data/weather";
import {
getDefaultForecastType,
getForecast,
getWeatherStateIcon,
subscribeForecast,
weatherSVGStyles,
} from "../../../data/weather";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type {
LovelaceCardFeatureContext,
LovelaceCardFeaturePosition,
WeatherForecastCardFeatureConfig,
} from "./types";
import { DEFAULT_FORECAST_SLOTS } from "../editor/config-elements/hui-weather-forecast-card-feature-editor";
export const supportsWeatherForecastCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? (hass.states[context.entity_id] as WeatherEntity | undefined)
: undefined;
if (!stateObj || computeDomain(stateObj.entity_id) !== "weather") {
return false;
}
return Boolean(
getDefaultForecastType(stateObj) ||
(stateObj.attributes.forecast?.length || 0) > 2
);
};
@customElement("hui-weather-forecast-card-feature")
class HuiWeatherForecastCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@property({ reflect: true })
public position?: LovelaceCardFeaturePosition;
@state() private _config?: WeatherForecastCardFeatureConfig;
@state() private _forecastEvent?: ForecastEvent;
@state() private _forecastType?: ForecastType;
@state() private _subscribed?: Promise<() => void>;
private _dragScrollController = new DragScrollController(this, {
selector: ".forecast",
enabled: false,
});
static getStubConfig(): WeatherForecastCardFeatureConfig {
return {
type: "weather-forecast",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-weather-forecast-card-feature-editor");
return document.createElement("hui-weather-forecast-card-feature-editor");
}
public setConfig(config: WeatherForecastCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._subscribeForecastEvents();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeForecastEvents();
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
const nextForecastType = this._effectiveForecastType;
const forecastTypeChanged = nextForecastType !== this._forecastType;
if (forecastTypeChanged) {
this._forecastType = nextForecastType;
}
if (
changedProps.has("context") ||
changedProps.has("_config") ||
forecastTypeChanged ||
!this._subscribed
) {
this._subscribeForecastEvents();
}
this._dragScrollController.enabled = Boolean(this._forecast?.length);
}
protected render() {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateObj ||
!supportsWeatherForecastCardFeature(this.hass, this.context)
) {
return nothing;
}
const forecast = this._forecast;
if (!forecast?.length) {
return nothing;
}
const temperatureFormatOptions = this._config.round_temperature
? { maximumFractionDigits: 0 }
: undefined;
const forecastType = getForecast(
this._stateObj.attributes,
this._forecastEvent,
this._forecastType
)?.type;
const hourly = forecastType === "hourly";
const dayNight = forecastType === "twice_daily";
const todayKey = this._dayKeyFromDate(new Date());
return html`
<div
class=${classMap({
forecast: true,
dragging: this._dragScrollController.scrolling,
})}
>
${forecast.map(
(item, index) => html`
${this._renderDayGroupLabel(
item,
index,
forecast,
dayNight,
hourly,
todayKey
)}
<div class="item">
<div class="label">
${this._labelForForecast(item, hourly, dayNight)}
</div>
${item.condition
? html`
<div class="icon">
${getWeatherStateIcon(
item.condition,
this,
!(item.is_daytime || item.is_daytime === undefined)
)}
</div>
`
: nothing}
<div class="temp">
${item.temperature !== undefined && item.temperature !== null
? `${formatNumber(
item.temperature,
this.hass!.locale,
temperatureFormatOptions
)}°`
: "—"}
</div>
</div>
`
)}
</div>
`;
}
private _renderDayGroupLabel(
item: ForecastAttribute,
index: number,
forecast: ForecastAttribute[],
dayNight: boolean,
hourly: boolean,
todayKey: string
) {
if (!dayNight && !hourly) {
return nothing;
}
const previousItem = forecast[index - 1];
const itemDayKey = this._dayKeyForForecast(item);
const dayChanged =
!previousItem || itemDayKey !== this._dayKeyForForecast(previousItem);
if (!dayChanged || itemDayKey === todayKey) {
return nothing;
}
return html`<div class="item label-only">
<div class="label">${this._dayLabelForForecast(item)}</div>
</div>`;
}
private get _stateObj() {
if (!this.hass || !this.context?.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id] as
| WeatherEntity
| undefined;
}
private get _forecast() {
const stateObj = this._stateObj;
if (!stateObj) {
return undefined;
}
return getForecast(
stateObj.attributes,
this._forecastEvent,
this._forecastType
)?.forecast?.slice(
0,
this._config?.forecast_slots ?? DEFAULT_FORECAST_SLOTS
);
}
private get _effectiveForecastType(): ForecastType | undefined {
const stateObj = this._stateObj;
if (!stateObj) {
return undefined;
}
if (this._config?.forecast_type !== undefined) {
return this._config.forecast_type;
}
return (
getDefaultForecastType(stateObj) ??
((stateObj.attributes.forecast?.length || 0) > 2 ? "legacy" : undefined)
);
}
private get _modernForecastType(): ModernForecastType | undefined {
return this._forecastType !== "legacy" ? this._forecastType : undefined;
}
private _unsubscribeForecastEvents() {
if (this._subscribed) {
this._subscribed.then((unsub) => unsub());
this._subscribed = undefined;
}
this._forecastEvent = undefined;
}
private _subscribeForecastEvents() {
this._unsubscribeForecastEvents();
const modernForecastType = this._modernForecastType;
if (
!this.isConnected ||
!this.hass ||
!this._stateObj ||
!modernForecastType
) {
return;
}
this._subscribed = subscribeForecast(
this.hass,
this._stateObj.entity_id,
modernForecastType,
(event) => {
this._forecastEvent = event;
}
);
}
private _labelForForecast(
item: ForecastAttribute,
hourly: boolean,
dayNight: boolean
) {
if (hourly) {
return formatTime(
new Date(item.datetime),
this.hass!.locale,
this.hass!.config
);
}
if (dayNight) {
return item.is_daytime !== false
? this.hass!.localize("ui.card.weather.day")
: this.hass!.localize("ui.card.weather.night");
}
return this._dayLabelForForecast(item);
}
private _dayLabelForForecast(item: ForecastAttribute) {
return formatDateWeekdayShort(
new Date(item.datetime),
this.hass!.locale,
this.hass!.config
);
}
private _dayKeyForForecast(item: ForecastAttribute) {
return this._dayKeyFromDate(new Date(item.datetime));
}
private _dayKeyFromDate(date: Date) {
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
}
static styles = [
weatherSVGStyles,
css`
:host {
display: block;
width: calc(100% + 16px);
margin: 0 -8px;
pointer-events: auto;
--icon-size: 28px;
}
:host([position="inline"]) {
--icon-size: 20px;
}
.forecast {
display: flex;
justify-content: space-between;
max-width: 100%;
overflow: auto;
scrollbar-color: var(--scrollbar-thumb-color) transparent;
scrollbar-width: none;
mask-image: linear-gradient(
90deg,
transparent 0%,
black 16px,
black calc(100% - 16px),
transparent 100%
);
user-select: none;
cursor: grab;
}
.forecast.dragging {
cursor: grabbing;
}
.forecast.dragging * {
pointer-events: none;
}
.forecast::-webkit-scrollbar {
display: none;
}
.forecast::before,
.forecast::after {
content: "";
position: relative;
display: block;
min-width: 8px;
height: 1px;
flex: 0 0 auto;
}
.item {
display: flex;
min-width: 40px;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--ha-space-1);
}
.item.label-only {
justify-content: flex-start;
}
.item.label-only .label {
color: var(--secondary-text-color);
font-weight: var(--ha-font-weight-bold);
}
.label,
.temp {
line-height: 1;
white-space: nowrap;
}
.label {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: var(--icon-size);
height: var(--icon-size);
}
.icon > * {
width: 100%;
height: 100%;
--mdc-icon-size: var(--icon-size);
}
.temp {
font-size: var(--ha-font-size-m);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-weather-forecast-card-feature": HuiWeatherForecastCardFeature;
}
}
@@ -1,5 +1,6 @@
import type { AlarmMode } from "../../../data/alarm_control_panel";
import type { HvacMode } from "../../../data/climate";
import type { ForecastType } from "../../../data/weather";
import type { OperationMode } from "../../../data/water_heater";
export type ButtonCardData = Record<string, any>;
@@ -226,6 +227,13 @@ export interface TrendGraphCardFeatureConfig {
detail?: boolean;
}
export interface WeatherForecastCardFeatureConfig {
type: "weather-forecast";
forecast_type?: ForecastType;
forecast_slots?: number;
round_temperature?: boolean;
}
export const AREA_CONTROL_DOMAINS = [
"light",
"fan",
@@ -299,6 +307,7 @@ export type LovelaceCardFeatureConfig =
| TargetHumidityCardFeatureConfig
| TargetTemperatureCardFeatureConfig
| ToggleCardFeatureConfig
| WeatherForecastCardFeatureConfig
| UpdateActionsCardFeatureConfig
| VacuumCommandsCardFeatureConfig
| ValveOpenCloseCardFeatureConfig
@@ -332,6 +332,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
.context=${this._featureContext}
.color=${this._config.color}
.features=${features}
.position=${featurePosition}
></hui-card-features>
`
: nothing}
@@ -40,6 +40,7 @@ import "../card-features/hui-vacuum-commands-card-feature";
import "../card-features/hui-valve-open-close-card-feature";
import "../card-features/hui-valve-position-favorite-card-feature";
import "../card-features/hui-valve-position-card-feature";
import "../card-features/hui-weather-forecast-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";
@@ -97,6 +98,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"valve-open-close",
"valve-position-favorite",
"valve-position",
"weather-forecast",
"water-heater-operation-modes",
]);
@@ -68,6 +68,7 @@ import { supportsVacuumCommandsCardFeature } from "../../card-features/hui-vacuu
import { supportsValveOpenCloseCardFeature } from "../../card-features/hui-valve-open-close-card-feature";
import { supportsValvePositionFavoriteCardFeature } from "../../card-features/hui-valve-position-favorite-card-feature";
import { supportsValvePositionCardFeature } from "../../card-features/hui-valve-position-card-feature";
import { supportsWeatherForecastCardFeature } from "../../card-features/hui-weather-forecast-card-feature";
import { supportsWaterHeaterOperationModesCardFeature } from "../../card-features/hui-water-heater-operation-modes-card-feature";
import type {
LovelaceCardFeatureConfig,
@@ -129,6 +130,7 @@ const UI_FEATURE_TYPES = [
"valve-open-close",
"valve-position-favorite",
"valve-position",
"weather-forecast",
"water-heater-operation-modes",
] as const satisfies readonly FeatureType[];
@@ -158,6 +160,7 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"update-actions",
"vacuum-commands",
"valve-position-favorite",
"weather-forecast",
"water-heater-operation-modes",
]);
@@ -211,6 +214,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
"valve-open-close": supportsValveOpenCloseCardFeature,
"valve-position-favorite": supportsValvePositionFavoriteCardFeature,
"valve-position": supportsValvePositionCardFeature,
"weather-forecast": supportsWeatherForecastCardFeature,
"water-heater-operation-modes": supportsWaterHeaterOperationModesCardFeature,
};
@@ -0,0 +1,206 @@
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 { supportsFeature } from "../../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type {
ForecastType,
ModernForecastType,
WeatherEntity,
} from "../../../../data/weather";
import { WeatherEntityFeature } from "../../../../data/weather";
import type { HomeAssistant } from "../../../../types";
import type {
LovelaceCardFeatureContext,
WeatherForecastCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
export const DEFAULT_FORECAST_SLOTS = 12;
export const MAX_FORECAST_SLOTS = 48;
@customElement("hui-weather-forecast-card-feature-editor")
export class HuiWeatherForecastCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: WeatherForecastCardFeatureConfig;
public setConfig(config: WeatherForecastCardFeatureConfig): void {
this._config = config;
}
private get _stateObj(): WeatherEntity | undefined {
if (!this.hass || !this.context?.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id] as
| WeatherEntity
| undefined;
}
private _forecastSupported(forecastType: ForecastType): boolean {
const stateObj = this._stateObj;
if (!stateObj) {
return false;
}
if (forecastType === "daily") {
return supportsFeature(stateObj, WeatherEntityFeature.FORECAST_DAILY);
}
if (forecastType === "hourly") {
return supportsFeature(stateObj, WeatherEntityFeature.FORECAST_HOURLY);
}
if (forecastType === "twice_daily") {
return supportsFeature(
stateObj,
WeatherEntityFeature.FORECAST_TWICE_DAILY
);
}
return false;
}
private get _defaultForecastType(): ModernForecastType | undefined {
if (this._forecastSupported("daily")) {
return "daily";
}
if (this._forecastSupported("hourly")) {
return "hourly";
}
if (this._forecastSupported("twice_daily")) {
return "twice_daily";
}
return undefined;
}
private _schema = memoizeOne(
(
localize: LocalizeFunc,
hasDaily: boolean,
hasHourly: boolean,
hasTwiceDaily: boolean
) => {
const forecastTypeOptions = [
...(hasDaily
? ([
{
value: "daily",
label: localize(
"ui.panel.lovelace.editor.features.types.weather-forecast.type_list.daily"
),
},
] as const)
: []),
...(hasHourly
? ([
{
value: "hourly",
label: localize(
"ui.panel.lovelace.editor.features.types.weather-forecast.type_list.hourly"
),
},
] as const)
: []),
...(hasTwiceDaily
? ([
{
value: "twice_daily",
label: localize(
"ui.panel.lovelace.editor.features.types.weather-forecast.type_list.twice_daily"
),
},
] as const)
: []),
];
return [
...(forecastTypeOptions.length
? ([
{
name: "forecast_type",
default: forecastTypeOptions[0].value,
selector: {
select: {
options: forecastTypeOptions,
},
},
},
] as const)
: []),
{
name: "forecast_slots",
default: DEFAULT_FORECAST_SLOTS,
selector: {
number: {
min: 1,
max: MAX_FORECAST_SLOTS,
mode: "slider",
},
},
},
{
name: "round_temperature",
selector: { boolean: {} },
},
] as const;
}
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const defaultForecastType = this._defaultForecastType;
const schema = this._schema(
this.hass.localize,
this._forecastSupported("daily"),
this._forecastSupported("hourly"),
this._forecastSupported("twice_daily")
);
const data: WeatherForecastCardFeatureConfig = {
...this._config,
forecast_slots: this._config.forecast_slots ?? DEFAULT_FORECAST_SLOTS,
forecast_type: this._config.forecast_type ?? defaultForecastType,
type: "weather-forecast",
};
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>>
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.weather-forecast.${schema.name}`
);
}
declare global {
interface HTMLElementTagNameMap {
"hui-weather-forecast-card-feature-editor": HuiWeatherForecastCardFeatureEditor;
}
}
+11
View File
@@ -9923,6 +9923,17 @@
"target-humidity": {
"label": "Target humidity"
},
"weather-forecast": {
"label": "Weather forecast",
"forecast_type": "[%key:ui::panel::lovelace::editor::card::weather-forecast::forecast_type%]",
"forecast_slots": "[%key:ui::panel::lovelace::editor::card::weather-forecast::forecast_slots%]",
"round_temperature": "[%key:ui::panel::lovelace::editor::card::generic::round_temperature%]",
"type_list": {
"daily": "[%key:ui::panel::lovelace::editor::card::weather-forecast::daily%]",
"hourly": "[%key:ui::panel::lovelace::editor::card::weather-forecast::hourly%]",
"twice_daily": "[%key:ui::panel::lovelace::editor::card::weather-forecast::twice_daily%]"
}
},
"water-heater-operation-modes": {
"label": "Water heater operation modes",
"operation_modes": "Operation modes",