Compare commits

...

1 Commits

Author SHA1 Message Date
Paul Bottein
dde2230185 Rework weather forecast card features 2026-05-05 10:01:06 +02:00
10 changed files with 1419 additions and 3 deletions

View File

@@ -0,0 +1,34 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import {
getDefaultForecastType,
getSupportedForecastTypes,
WeatherEntityFeature,
} from "../../../../data/weather";
import type { ForecastResolution } from "../types";
export const DEFAULT_DAYS_TO_SHOW = 7;
export const DEFAULT_HOURS_TO_SHOW = 24;
export const MS_PER_HOUR = 60 * 60 * 1000;
export const supportsForecast = (stateObj: HassEntity | undefined): boolean => {
if (!stateObj) return false;
if (computeDomain(stateObj.entity_id) !== "weather") return false;
return (
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_DAILY) ||
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_TWICE_DAILY) ||
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_HOURLY)
);
};
export const resolveForecastResolution = (
stateObj: HassEntity | undefined,
configured?: ForecastResolution
): ForecastResolution | undefined => {
if (!stateObj) return undefined;
const supported = getSupportedForecastTypes(stateObj);
if (configured && supported.includes(configured)) return configured;
return getDefaultForecastType(stateObj);
};

View File

@@ -0,0 +1,443 @@
import { consume } from "@lit/context";
import type {
Connection,
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 { computeCssColor } from "../../../common/color/compute-color";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-spinner";
import {
connectionContext,
internationalizationContext,
} from "../../../data/context";
import type { ForecastAttribute, ForecastEvent } from "../../../data/weather";
import {
getForecastPrecipitation,
subscribeForecast,
} from "../../../data/weather";
import type {
HomeAssistant,
HomeAssistantConnection,
HomeAssistantInternationalization,
} from "../../../types";
import {
DEFAULT_DAYS_TO_SHOW,
DEFAULT_HOURS_TO_SHOW,
MS_PER_HOUR,
resolveForecastResolution,
supportsForecast,
} from "./common/forecast";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type {
ForecastResolution,
LovelaceCardFeatureContext,
PrecipitationForecastCardFeatureConfig,
} from "./types";
const MAX_BAR_WIDTH = 16;
export const supportsPrecipitationForecastCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) =>
supportsForecast(
context.entity_id ? hass.states[context.entity_id] : undefined
);
@customElement("hui-precipitation-forecast-card-feature")
class HuiPrecipitationForecastCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state()
@consumeEntityState({ entityIdPath: ["context", "entity_id"] })
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?: PrecipitationForecastCardFeatureConfig;
@state() private _forecast?: ForecastAttribute[];
@state() private _error?: string;
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
private _subscribedType?: ForecastResolution;
static getStubConfig(): PrecipitationForecastCardFeatureConfig {
return {
type: "precipitation-forecast",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-precipitation-forecast-card-feature-editor");
return document.createElement(
"hui-precipitation-forecast-card-feature-editor"
) as LovelaceCardFeatureEditor;
}
public setConfig(config: PrecipitationForecastCardFeatureConfig): 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(): ForecastResolution | undefined {
return resolveForecastResolution(
this._stateObj,
this._config?.forecast_type
);
}
protected render() {
if (!this._config || !this.context || !supportsForecast(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 isHourly = this._subscribedType === "hourly";
const precipitationType = this._config.precipitation_type ?? "amount";
const customColor = this._config.color
? computeCssColor(this._config.color)
: undefined;
const fill = customColor ?? "var(--state-weather-rainy-color)";
if (isHourly) {
return html`
<div class="container">
${this._renderHourlyBars(precipitationType, fill)}
</div>
`;
}
const daysToShow = this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW;
const entriesPerDay = this._subscribedType === "twice_daily" ? 2 : 1;
const entries = this._forecast.slice(0, daysToShow * entriesPerDay);
if (!entries.length) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.no_forecast"
)}
</div>
</div>
`;
}
return html`
<div class="container">
${this._renderDailyBars(entries, precipitationType, fill)}
</div>
`;
}
private _renderDailyBars(
entries: ForecastAttribute[],
precipitationType: "amount" | "probability",
fill: string
): 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 drawableHeight = height - padding * 2;
let maxPrecipitation = 0;
if (precipitationType === "probability") {
maxPrecipitation = 100;
} else {
for (const entry of entries) {
const value = getForecastPrecipitation(entry, precipitationType);
if (Number.isFinite(value)) {
maxPrecipitation = Math.max(maxPrecipitation, value!);
}
}
}
const dotRadius = 1.5;
const elements = entries.map((entry, i) => {
const value = getForecastPrecipitation(entry, precipitationType);
const x = slotWidth * i + slotWidth / 2;
if (!Number.isFinite(value) || value! <= 0 || maxPrecipitation <= 0) {
const cy = padding + drawableHeight - dotRadius;
return svg`<circle
cx=${x}
cy=${cy}
r=${dotRadius}
fill=${fill}
opacity="0.4"
></circle>`;
}
const barHeight = Math.max(
1,
(value! / maxPrecipitation) * drawableHeight
);
const y = padding + drawableHeight - barHeight;
return svg`<rect
x=${x - barWidth / 2}
y=${y}
width=${barWidth}
height=${barHeight}
fill=${fill}
opacity="0.4"
></rect>`;
});
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${elements}
</svg>
`;
}
private _renderHourlyBars(
precipitationType: "amount" | "probability",
fill: string
): TemplateResult | typeof nothing {
if (!this._forecast?.length) {
return nothing;
}
const width = this.clientWidth || 300;
const height = this.clientHeight || 42;
const topPadding = 4;
const drawableHeight = height - topPadding;
const now = Date.now();
const hoursToShow = this._config!.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
const maxTime =
Math.floor((now + hoursToShow * MS_PER_HOUR) / MS_PER_HOUR) * MS_PER_HOUR;
const timeRange = maxTime - now;
if (timeRange <= 0) {
return nothing;
}
const inRange: { entry: ForecastAttribute; t: number }[] = [];
for (const entry of this._forecast) {
const t = new Date(entry.datetime).getTime();
if (t >= now && t <= maxTime) {
inRange.push({ entry, t });
}
}
if (!inRange.length) {
return nothing;
}
const entriesWithRain = inRange.filter(({ entry }) => {
const value = getForecastPrecipitation(entry, precipitationType);
return Number.isFinite(value) && value! > 0;
});
let maxPrecipitation = 0;
if (precipitationType === "probability") {
maxPrecipitation = 100;
} else {
for (const { entry } of entriesWithRain) {
maxPrecipitation = Math.max(
maxPrecipitation,
getForecastPrecipitation(entry, precipitationType)!
);
}
}
const slotWidth = width / hoursToShow;
const barWidth = Math.max(1, Math.min(MAX_BAR_WIDTH, slotWidth - 2));
const dotRadius = 1.5;
const elements = inRange.map(({ entry, t }) => {
const value = getForecastPrecipitation(entry, precipitationType);
const xCenter = ((t - now) / timeRange) * width;
if (!Number.isFinite(value) || value! <= 0 || maxPrecipitation <= 0) {
const cy = height - dotRadius;
return svg`<circle
cx=${xCenter}
cy=${cy}
r=${dotRadius}
fill=${fill}
opacity="0.4"
></circle>`;
}
const x = xCenter - barWidth / 2;
const barHeight = Math.max(
1,
(value! / maxPrecipitation) * drawableHeight
);
const y = height - barHeight;
return svg`<rect
x=${x}
y=${y}
width=${barWidth}
height=${barHeight}
fill=${fill}
opacity="0.4"
></rect>`;
});
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${elements}
</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-precipitation-forecast-card-feature": HuiPrecipitationForecastCardFeature;
}
}

View File

@@ -0,0 +1,502 @@
import { consume } from "@lit/context";
import type {
Connection,
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 { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-spinner";
import {
connectionContext,
internationalizationContext,
} from "../../../data/context";
import type { ForecastAttribute, ForecastEvent } from "../../../data/weather";
import { subscribeForecast } from "../../../data/weather";
import type {
HomeAssistant,
HomeAssistantConnection,
HomeAssistantInternationalization,
} from "../../../types";
import {
DEFAULT_DAYS_TO_SHOW,
DEFAULT_HOURS_TO_SHOW,
MS_PER_HOUR,
resolveForecastResolution,
supportsForecast,
} from "./common/forecast";
import { coordinates } from "../common/graph/coordinates";
import type { HuiGraphGradient } from "../components/hui-graph-base";
import "../components/hui-graph-base";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type {
ForecastResolution,
LovelaceCardFeatureContext,
TemperatureForecastCardFeatureConfig,
} from "./types";
const MAX_BAR_WIDTH = 8;
const TEMP_GRADIENT_STOPS: { tempC: number; cssVar: string }[] = [
{ tempC: -20, cssVar: "--feature-temperature-freezing-color" },
{ tempC: 0, cssVar: "--feature-temperature-cold-color" },
{ tempC: 15, cssVar: "--feature-temperature-mild-color" },
{ tempC: 25, cssVar: "--feature-temperature-warm-color" },
{ tempC: 40, cssVar: "--feature-temperature-hot-color" },
];
export const supportsTemperatureForecastCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) =>
supportsForecast(
context.entity_id ? hass.states[context.entity_id] : undefined
);
@customElement("hui-temperature-forecast-card-feature")
class HuiTemperatureForecastCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state()
@consumeEntityState({ entityIdPath: ["context", "entity_id"] })
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?: TemperatureForecastCardFeatureConfig;
@state() private _forecast?: ForecastAttribute[];
@state() private _hourly?: {
coordinates: [number, number][];
yAxisOrigin: number;
gradient?: HuiGraphGradient;
};
@state() private _error?: string;
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
private _subscribedType?: ForecastResolution;
static getStubConfig(): TemperatureForecastCardFeatureConfig {
return {
type: "temperature-forecast",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-temperature-forecast-card-feature-editor");
return document.createElement(
"hui-temperature-forecast-card-feature-editor"
) as LovelaceCardFeatureEditor;
}
public setConfig(config: TemperatureForecastCardFeatureConfig): 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(): ForecastResolution | undefined {
return resolveForecastResolution(
this._stateObj,
this._config?.forecast_type
);
}
protected render() {
if (!this._config || !this.context || !supportsForecast(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 isHourly = this._subscribedType === "hourly";
const customColor = this._config.color
? computeCssColor(this._config.color)
: undefined;
if (isHourly) {
if (!this._hourly?.coordinates.length) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.temperature-forecast.no_forecast"
)}
</div>
</div>
`;
}
const graphStyle = customColor
? styleMap({ "--feature-color": customColor })
: nothing;
return html`
<div class="container">
<hui-graph-base
.coordinates=${this._hourly.coordinates}
.yAxisOrigin=${this._hourly.yAxisOrigin}
.gradient=${customColor ? undefined : this._hourly.gradient}
style=${graphStyle}
></hui-graph-base>
</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) =>
Number.isFinite(entry.temperature) && Number.isFinite(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.temperature-forecast.no_forecast"
)}
</div>
</div>
`;
}
return html`
<div class="container">${this._renderBars(entries, customColor)}</div>
`;
}
private _renderBars(
entries: ForecastAttribute[],
customColor: string | undefined
): 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 drawableHeight = height - padding * 2;
let tempMin = Infinity;
let tempMax = -Infinity;
for (const entry of entries) {
tempMin = Math.min(tempMin, entry.templow!);
tempMax = Math.max(tempMax, entry.temperature);
}
if (tempMin === tempMax) {
tempMin -= 1;
tempMax += 1;
}
const yFor = (value: number) =>
padding +
drawableHeight -
((value - tempMin) / (tempMax - tempMin)) * drawableHeight;
const isFahrenheit = this._stateObj?.attributes?.temperature_unit === "°F";
const toDisplayUnit = (tempC: number) =>
isFahrenheit ? (tempC * 9) / 5 + 32 : tempC;
const tempGradient = !customColor
? (() => {
const stops = TEMP_GRADIENT_STOPS.map((stop) => ({
y: yFor(toDisplayUnit(stop.tempC)),
cssVar: stop.cssVar,
})).sort((a, b) => a.y - b.y);
const y1 = stops[0].y;
const y2 = stops[stops.length - 1].y;
const range = y2 - y1 || 1;
return svg`<defs>
<linearGradient
id="temp-gradient"
gradientUnits="userSpaceOnUse"
x1="0" y1=${y1}
x2="0" y2=${y2}
>
${stops.map(
(stop) =>
svg`<stop
offset=${(stop.y - y1) / range}
style="stop-color: var(${stop.cssVar})"
></stop>`
)}
</linearGradient>
</defs>`;
})()
: nothing;
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 = customColor ?? "url(#temp-gradient)";
return svg`<rect
x=${x}
y=${yHigh}
width=${barWidth}
height=${barHeight}
rx=${rx}
ry=${rx}
fill=${fill}
></rect>`;
});
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${tempGradient}${bars}
</svg>
`;
}
private _unsubscribeForecast() {
if (this._subscribed) {
this._subscribed.then((unsub) => unsub?.()).catch(() => undefined);
this._subscribed = undefined;
}
this._subscribedType = undefined;
this._hourly = undefined;
}
private _computeHourly(forecast: ForecastAttribute[]) {
if (!forecast.length || !this._stateObj) {
this._hourly = undefined;
return;
}
const data: [number, number][] = [];
const now = Date.now();
const hoursToShow = this._config!.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
const maxTime =
Math.floor((now + hoursToShow * MS_PER_HOUR) / MS_PER_HOUR) * MS_PER_HOUR;
const currentTemp = this._stateObj.attributes?.temperature;
if (currentTemp != null && !Number.isNaN(Number(currentTemp))) {
data.push([now, Number(currentTemp)]);
}
for (const entry of 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._hourly = undefined;
return;
}
let dataMin = data[0][1];
let dataMax = data[0][1];
for (const [, t] of data) {
if (t < dataMin) dataMin = t;
if (t > dataMax) dataMax = t;
}
const range = dataMax - dataMin || dataMin * 0.1;
const minY = dataMin - range * 0.1;
const maxY = dataMax + range * 0.1;
const width = this.clientWidth || 300;
const height = this.clientHeight || 42;
const { points, yAxisOrigin } = coordinates(
data,
width,
height,
data.length,
{ minX: now, maxX: maxTime, minY, maxY }
);
points.pop();
const isFahrenheit = this._stateObj.attributes?.temperature_unit === "°F";
const toDisplayUnit = (tempC: number) =>
isFahrenheit ? (tempC * 9) / 5 + 32 : tempC;
const yFor = (temp: number) =>
height - ((temp - minY) / (maxY - minY || 1)) * height;
const stops = TEMP_GRADIENT_STOPS.map((stop) => ({
y: yFor(toDisplayUnit(stop.tempC)),
cssVar: stop.cssVar,
})).sort((a, b) => a.y - b.y);
const y1 = stops[0].y;
const y2 = stops[stops.length - 1].y;
const gradientRange = y2 - y1 || 1;
const gradient: HuiGraphGradient = {
x1: 0,
y1,
x2: 0,
y2,
stops: stops.map((stop) => ({
offset: (stop.y - y1) / gradientRange,
color: `var(${stop.cssVar})`,
})),
};
this._hourly = { coordinates: points, yAxisOrigin, gradient };
}
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 ?? [];
if (this._subscribedType === "hourly") {
this._computeHourly(this._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;
--feature-temperature-freezing-color: #a89bd8;
--feature-temperature-cold-color: #7dc8dc;
--feature-temperature-mild-color: #a8dc7c;
--feature-temperature-warm-color: #e89042;
--feature-temperature-hot-color: #d24530;
}
.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;
}
hui-graph-base {
width: 100%;
height: 100%;
--accent-color: var(--feature-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-temperature-forecast-card-feature": HuiTemperatureForecastCardFeature;
}
}

View File

@@ -1,6 +1,7 @@
import type { AlarmMode } from "../../../data/alarm_control_panel";
import type { HvacMode } from "../../../data/climate";
import type { OperationMode } from "../../../data/water_heater";
import type { ForecastPrecipitationType } from "../../../data/weather";
export type ButtonCardData = Record<string, any>;
@@ -241,6 +242,25 @@ export interface TrendGraphCardFeatureConfig {
detail?: boolean;
}
export type ForecastResolution = "daily" | "twice_daily" | "hourly";
export interface TemperatureForecastCardFeatureConfig {
type: "temperature-forecast";
forecast_type?: ForecastResolution;
days_to_show?: number;
hours_to_show?: number;
color?: string;
}
export interface PrecipitationForecastCardFeatureConfig {
type: "precipitation-forecast";
forecast_type?: ForecastResolution;
days_to_show?: number;
hours_to_show?: number;
precipitation_type?: ForecastPrecipitationType;
color?: string;
}
export const AREA_CONTROL_DOMAINS = [
"light",
"fan",
@@ -295,6 +315,8 @@ export type LovelaceCardFeatureConfig =
| FanPresetModesCardFeatureConfig
| FanSpeedCardFeatureConfig
| TrendGraphCardFeatureConfig
| TemperatureForecastCardFeatureConfig
| PrecipitationForecastCardFeatureConfig
| HumidifierToggleCardFeatureConfig
| HumidifierModesCardFeatureConfig
| LawnMowerCommandsCardFeatureConfig

View File

@@ -1,9 +1,17 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, svg } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { strokeWidth } from "../../../data/graph";
import { getPath } from "../common/graph/get-path";
export interface HuiGraphGradient {
x1: number;
y1: number;
x2: number;
y2: number;
stops: { offset: number; color: string }[];
}
@customElement("hui-graph-base")
export class HuiGraphBase extends LitElement {
@property({ attribute: false }) public coordinates?: number[][];
@@ -11,6 +19,8 @@ export class HuiGraphBase extends LitElement {
@property({ attribute: "y-axis-origin", type: Number })
public yAxisOrigin?: number;
@property({ attribute: false }) public gradient?: HuiGraphGradient;
@state() private _path?: string;
private _uniqueId = `graph-${Math.random().toString(36).substring(2, 9)}`;
@@ -22,9 +32,29 @@ export class HuiGraphBase extends LitElement {
const lastX = this.coordinates?.length
? this.coordinates[this.coordinates.length - 1][0]
: width;
const fill = this.gradient
? `url(#${this._uniqueId}-gradient)`
: "var(--accent-color)";
return html`
${this._path
? svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
${
this.gradient
? svg`<defs>
<linearGradient
id="${this._uniqueId}-gradient"
gradientUnits="userSpaceOnUse"
x1=${this.gradient.x1} y1=${this.gradient.y1}
x2=${this.gradient.x2} y2=${this.gradient.y2}
>
${this.gradient.stops.map(
(s) =>
svg`<stop offset=${s.offset} style="stop-color: ${s.color}"></stop>`
)}
</linearGradient>
</defs>`
: nothing
}
<g>
<mask id="${this._uniqueId}-fill">
<path
@@ -33,7 +63,7 @@ export class HuiGraphBase extends LitElement {
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>
<rect height="100%" width="100%" fill=${fill} mask="url(#${this._uniqueId}-fill)"></rect>
<mask id="${this._uniqueId}-line">
<path
vector-effect="non-scaling-stroke"
@@ -46,7 +76,7 @@ export class HuiGraphBase extends LitElement {
d=${this._path}
></path>
</mask>
<rect height="100%" width="100%" fill="var(--accent-color)" mask="url(#${this._uniqueId}-line)"></rect>
<rect height="100%" width="100%" fill=${fill} mask="url(#${this._uniqueId}-line)"></rect>
</g>
</svg>`
: svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}"></svg>`}

View File

@@ -43,6 +43,8 @@ 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-precipitation-forecast-card-feature";
import "../card-features/hui-temperature-forecast-card-feature";
import "../card-features/hui-trend-graph-card-feature";
import type { LovelaceCardFeatureConfig } from "../card-features/types";
@@ -87,10 +89,12 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"media-player-volume-buttons",
"media-player-volume-slider",
"numeric-input",
"precipitation-forecast",
"select-options",
"trend-graph",
"target-humidity",
"target-temperature",
"temperature-forecast",
"toggle",
"update-actions",
"vacuum-commands",

View File

@@ -42,6 +42,8 @@ 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 { supportsPrecipitationForecastCardFeature } from "../../card-features/hui-precipitation-forecast-card-feature";
import { supportsTemperatureForecastCardFeature } from "../../card-features/hui-temperature-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";
@@ -119,10 +121,12 @@ const UI_FEATURE_TYPES = [
"media-player-volume-buttons",
"media-player-volume-slider",
"numeric-input",
"precipitation-forecast",
"select-options",
"trend-graph",
"target-humidity",
"target-temperature",
"temperature-forecast",
"toggle",
"update-actions",
"vacuum-commands",
@@ -149,6 +153,8 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"cover-tilt-favorite",
"fan-preset-modes",
"humidifier-modes",
"precipitation-forecast",
"temperature-forecast",
"lawn-mower-commands",
"media-player-playback",
"light-color-favorites",
@@ -202,10 +208,12 @@ const SUPPORTS_FEATURE_TYPES: Record<
"media-player-volume-buttons": supportsMediaPlayerVolumeButtonsCardFeature,
"media-player-volume-slider": supportsMediaPlayerVolumeSliderCardFeature,
"numeric-input": supportsNumericInputCardFeature,
"precipitation-forecast": supportsPrecipitationForecastCardFeature,
"select-options": supportsSelectOptionsCardFeature,
"trend-graph": supportsTrendGraphCardFeature,
"target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature,
"temperature-forecast": supportsTemperatureForecastCardFeature,
toggle: supportsToggleCardFeature,
"update-actions": supportsUpdateActionsCardFeature,
"vacuum-commands": supportsVacuumCommandsCardFeature,

View File

@@ -0,0 +1,187 @@
import type { HassEntity } from "home-assistant-js-websocket";
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,
DEFAULT_HOURS_TO_SHOW,
resolveForecastResolution,
} from "../../card-features/common/forecast";
import type {
ForecastResolution,
LovelaceCardFeatureContext,
PrecipitationForecastCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-precipitation-forecast-card-feature-editor")
export class HuiPrecipitationForecastCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: PrecipitationForecastCardFeatureConfig;
public setConfig(config: PrecipitationForecastCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(
stateObj: HassEntity | undefined,
forecastType: ForecastResolution,
localize: HomeAssistant["localize"]
) => {
const supportedTypes = stateObj
? getSupportedForecastTypes(stateObj)
: [];
const isHourly = forecastType === "hourly";
return [
{
name: "forecast_type",
required: true,
disabled: supportedTypes.length <= 1,
selector: {
select: {
mode: "dropdown",
options: (
["daily", "twice_daily", "hourly"] as ForecastResolution[]
).map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.features.types.precipitation-forecast.forecast_type_options.${value}`
),
disabled: !supportedTypes.includes(value),
})),
},
},
},
isHourly
? {
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
}
: {
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "precipitation_type",
required: true,
selector: {
select: {
mode: "dropdown",
options: [
{
value: "amount",
label: localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.precipitation_type_options.amount"
),
},
{
value: "probability",
label: localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.precipitation_type_options.probability"
),
},
],
},
},
},
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
},
] 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 resolvedType =
resolveForecastResolution(stateObj, this._config.forecast_type) ||
"daily";
const isHourly = resolvedType === "hourly";
const data: PrecipitationForecastCardFeatureConfig = {
...this._config,
forecast_type: resolvedType,
precipitation_type: this._config.precipitation_type ?? "amount",
...(isHourly
? { hours_to_show: this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW }
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
};
const schema = this._schema(stateObj, resolvedType, 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.temperature-forecast.forecast_type"
);
case "days_to_show":
case "hours_to_show":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
case "precipitation_type":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.precipitation_type"
);
case "color":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.color"
);
default:
return "";
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-precipitation-forecast-card-feature-editor": HuiPrecipitationForecastCardFeatureEditor;
}
}

View File

@@ -0,0 +1,159 @@
import type { HassEntity } from "home-assistant-js-websocket";
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,
DEFAULT_HOURS_TO_SHOW,
resolveForecastResolution,
} from "../../card-features/common/forecast";
import type {
ForecastResolution,
LovelaceCardFeatureContext,
TemperatureForecastCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-temperature-forecast-card-feature-editor")
export class HuiTemperatureForecastCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: TemperatureForecastCardFeatureConfig;
public setConfig(config: TemperatureForecastCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(
stateObj: HassEntity | undefined,
forecastType: ForecastResolution,
localize: HomeAssistant["localize"]
) => {
const supportedTypes = stateObj
? getSupportedForecastTypes(stateObj)
: [];
const isHourly = forecastType === "hourly";
return [
{
name: "forecast_type",
required: true,
disabled: supportedTypes.length <= 1,
selector: {
select: {
mode: "dropdown",
options: (
["daily", "twice_daily", "hourly"] as ForecastResolution[]
).map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.features.types.temperature-forecast.forecast_type_options.${value}`
),
disabled: !supportedTypes.includes(value),
})),
},
},
},
isHourly
? {
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
}
: {
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
},
] 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 resolvedType =
resolveForecastResolution(stateObj, this._config.forecast_type) ||
"daily";
const isHourly = resolvedType === "hourly";
const data: TemperatureForecastCardFeatureConfig = {
...this._config,
forecast_type: resolvedType,
...(isHourly
? { hours_to_show: this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW }
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
};
const schema = this._schema(stateObj, resolvedType, 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.temperature-forecast.forecast_type"
);
case "days_to_show":
case "hours_to_show":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
case "color":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.temperature-forecast.color"
);
default:
return "";
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-temperature-forecast-card-feature-editor": HuiTemperatureForecastCardFeatureEditor;
}
}

View File

@@ -10143,6 +10143,33 @@
"trend-graph": {
"label": "Trend graph",
"detail": "Show more detail"
},
"temperature-forecast": {
"label": "Temperature forecast",
"no_forecast": "No forecast data available",
"forecast_type": "Forecast type",
"forecast_type_options": {
"daily": "Daily",
"twice_daily": "Twice daily",
"hourly": "Hourly"
},
"color": "Color"
},
"precipitation-forecast": {
"label": "Precipitation forecast",
"no_forecast": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::no_forecast%]",
"forecast_type": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::forecast_type%]",
"forecast_type_options": {
"daily": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::forecast_type_options::daily%]",
"twice_daily": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::forecast_type_options::twice_daily%]",
"hourly": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::forecast_type_options::hourly%]"
},
"precipitation_type": "Precipitation type",
"precipitation_type_options": {
"amount": "Amount",
"probability": "Probability"
},
"color": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::color%]"
}
}
},